@juit/check-updates
Version:
Small and fast utility to update package dependencies
379 lines (311 loc) • 12.8 kB
text/typescript
import assert from 'node:assert'
import { EventEmitter } from 'node:events'
import { readFile, writeFile } from 'node:fs/promises'
import { relative, resolve } from 'node:path'
import semver from 'semver'
import { B, G, R, X, Y, makeDebug } from './debug'
import { readNpmRc } from './npmrc'
import type { ReleaseType } from 'semver'
import type { VersionsCache } from './versions'
export type UpdaterOptions = {
bump: ReleaseType | undefined,
debug: boolean,
quick: boolean,
strict: boolean,
workspaces: boolean,
}
const dependencyTypes = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
] as const
type DependencyType = (typeof dependencyTypes)[number]
interface PackageData {
name?: string,
version?: string,
dependencies?: Record<string, string>,
devDependencies?: Record<string, string>,
peerDependencies?: Record<string, string>,
optionalDependencies?: Record<string, string>,
}
interface DependencyChange {
name: string,
declared: string,
updated: string,
type: DependencyType,
}
class Workspaces {
private _versions: Record<string, string> = {}
private _emitter = new EventEmitter().setMaxListeners(100)
get length(): number {
return Object.entries(this._versions).length
}
onUpdate(handler: (name: string, version: string) => void): void {
this._emitter.on('update', handler)
}
register(name: string, version?: string): void {
assert(! this._versions[name], `Package "${name}" already registered`)
this._versions[name] = version || '0.0.0'
}
update(name: string, version: string): void {
assert(this._versions[name], `Package "${name}" not registered`)
const oldVersion = this._versions[name] || '0.0.0'
assert(semver.gte(version, oldVersion), `Package "${name}" new version ${version} less than old ${oldVersion}`)
if (semver.eq(version, oldVersion)) return
this._versions[name] = version
this._emitter.emit('update', name, version)
}
has(name: string): boolean {
return !! this._versions[name]
}
* [Symbol.iterator](): Generator<[ name: string, version: string ]> {
for (const [ name, version ] of Object.entries(this._versions)) {
yield [ name, version ]
}
}
}
export class Updater {
private _packageData?: PackageData
private _npmRc?: Record<string, any>
private _originalVersion?: string
private _debug: (...args: any[]) => void
private _children: Updater[]
private _changed = false
constructor(
private readonly _packageFile: string,
private readonly _options: UpdaterOptions,
private readonly _cache: VersionsCache,
private readonly _workspaces: Workspaces = new Workspaces(),
) {
this._packageFile = resolve(_packageFile)
this._debug = makeDebug(_options.debug)
this._children = []
_workspaces.onUpdate((name, version) => {
if (! this._packageData) return
for (const type of dependencyTypes) {
const dependencies = this._packageData[type]
if (! dependencies) continue
if (! dependencies[name]) continue
if (dependencies[name] === version) continue
dependencies[name] = version
this._changed = true
this._bump()
}
})
}
get name(): string | undefined {
assert(this._packageData, 'Updater not initialized')
return this._packageData.name
}
get version(): string {
assert(this._packageData, 'Updater not initialized')
return this._packageData.version || '0.0.0'
}
set version(version: string) {
assert(this._originalVersion && this._packageData, 'Updater not initialized')
assert(semver.lte(this._originalVersion, version), [
`Unable to set version for "${this.packageFile}" to "${version}"`,
`as it's less than original version "${this._originalVersion}"`,
].join(' '))
if (semver.eq(this._originalVersion, version)) return
if (this._packageData.version === version) return
this._changed = true
console.log(`Updating ${this._details} version to ${Y}${version}${X}`)
this._packageData.version = version
if (this.name) this._workspaces.update(this.name, version)
}
get packageFile(): string {
return relative(process.cwd(), this._packageFile)
}
get changed(): boolean {
if (this._changed) return true
return this._children.reduce((changed, child) => changed || child.changed, false)
}
async init(): Promise<this> {
this._debug('Reading package file', this.packageFile)
/* Parse our package file */
const json = await readFile(this._packageFile, 'utf8')
const data = this._packageData = JSON.parse(json)
assert(data && (typeof data === 'object') && (! Array.isArray(data)),
`File ${this.packageFile} is not a valid "pacakge.json" file`)
/* Parse the ".npmrc" relative to the package file */
const npmrc = await readNpmRc(this._packageFile)
/* Register this package in our workspaces and set the original version */
if (data.name) this._workspaces.register(data.name, data.version)
this._originalVersion = data.version || '0.0.0'
/* Read up our workspaces */
if (this._options.workspaces && data.workspaces) {
for (const path of data.workspaces) {
const packageFile = resolve(this._packageFile, '..', path, 'package.json')
const updater = new Updater(packageFile, this._options, this._cache, this._workspaces)
this._children.push(await updater.init())
}
}
/* Done */
this._packageData = data
this._npmRc = npmrc
return this
}
private get _details(): string {
let string = `${G}${this.packageFile}${X}`
if (this.name || this.version) {
string += ` [${Y}`
if (this.name) string += `${this.name}`
if (this.name && this.version) string += ' '
if (this.version) string += `${this.version}`
string += `${X}]`
}
return string
}
private _bump(): void {
assert(this._originalVersion, 'Updater not initialized')
if (this._options.bump) {
this.version = semver.inc(this._originalVersion, this._options.bump) || this._originalVersion
}
}
/** Update a single dependency, returning the highest matching version */
private async _updateDependency(name: string, rangeString: string): Promise<string> {
assert(this._npmRc, 'Updater not initialized')
/* Check if this is a workspace package */
if (this._workspaces.has(name)) {
this._debug(`Not processing workspace package ${Y}${name}${X}`)
return rangeString
}
/* Check that we have a proper range (^x... or ~x...) */
const match = /^\s*([~^])\s*(\d+(\.\d+(\.\d+)?)?)(-(alpha|beta|rc)[.-]\d+)?\s*$/.exec(rangeString)
if (! match) {
this._debug(`Not processing range ${G}${rangeString}${X} for ${Y}${name}${X}`)
return rangeString
}
/* Extract specifier and version from the string range*/
const [ , specifier = '', version = '', , , label = '' ] = match
/* Extend range if not in strict mode */
if (! this._options.strict) {
const r = rangeString
rangeString = `>=${version}${label}`
if (specifier === '~') rangeString += ` <${semver.inc(version, 'major')}`
this._debug(`Extending version for ${Y}${name}${X} from ${G}${r}${X} to ${G}${rangeString}${X}`)
}
const range = new semver.Range(rangeString)
/* Get the highest matching RELEASE version and return it. If the dependency
* has a label, and a RELEASE version fullfills it, then we basically drop
* any pre-release info and upgrade to RELEASE */
const versions = await this._cache.getVersions(name, this._npmRc, false)
for (const v of versions) {
if (range.test(v)) return `${specifier}${v}`
}
/* If we're still here and the dependency is marked with a label, also try
* pre-releases: this ensures we upgrade pre-releases up to the point of a
* new release version (which is catched above) */
if (label) {
const versions = await this._cache.getVersions(name, this._npmRc, true)
for (const v of versions) {
if (range.test(v)) return `${specifier}${v}`
}
}
/* No version found, return the original one cleaned up */
return `${specifier}${version}${label}`
}
/** Update a dependencies group, populating the "updated" version field */
private async _updateDependenciesGroup(type: DependencyType): Promise<DependencyChange []> {
assert(this._packageData, 'Updater not initialized')
const dependencies = this._packageData[type]
if (! dependencies) return []
/* Parallelize updates for this group */
const promises = Object.entries(dependencies)
.map(async ([ name, declared ]) => {
const updated = await this._updateDependency(name, declared)
if (! this._options.debug) process.stdout.write('.')
if (updated === declared) return
dependencies[name] = updated
return { name, declared, updated, type } satisfies DependencyChange
})
/* Await all updates and return changes */
return (await Promise.all(promises))
.filter((change): change is DependencyChange => !! change)
}
/** Update dependencies and return the version number of this package */
async update(): Promise<void> {
assert(this._packageData, 'Updater not initialized')
/* Start by processing all workspaces first */
for (const child of this._children) await child.update()
/* Some pretty printing of our package name and version */
process.stdout.write(`Processing ${this._details} `)
if (this._options.debug) process.stdout.write('\n')
/* Process the _main_ dependencies group first */
const changes = await this._updateDependenciesGroup('dependencies')
/* Process all the other dependencies if we need to do so */
if (changes.length || (! this._options.quick)) {
changes.push(...await this._updateDependenciesGroup('devDependencies'))
changes.push(...await this._updateDependenciesGroup('optionalDependencies'))
changes.push(...await this._updateDependenciesGroup('peerDependencies'))
}
/* Simply return if no changes were detected or mark this as changed */
if (this._options.debug) process.stdout.write('Updated with')
if (! changes.length) return void console.log(` ${R}no changes${X}`)
this._changed = true
/* Really pretty print all our changed dependencies */
changes.sort(({ name: a }, { name: b }) => a < b ? -1 : a > b ? 1 : 0)
console.log(` ${R}${changes.length} changes${X}`)
let lname = 0
let ldeclared = 0
let lupdated = 0
for (const { name, declared, updated } of changes) {
lname = lname > name.length ? lname : name.length
ldeclared = ldeclared > declared.length ? ldeclared : declared.length
lupdated = lupdated > updated.length ? lupdated : updated.length
}
for (const { name, declared, updated, type } of changes) {
const kind =
type === 'devDependencies' ? 'dev' :
type === 'peerDependencies' ? 'peer' :
type === 'optionalDependencies' ? 'optional' :
'main'
console.log([
` * ${Y}${name.padEnd(lname)}${X}`,
` : ${G}${declared.padStart(ldeclared)}${X}`,
` -> ${G}${updated.padEnd(lupdated)} ${B}${kind}${X}`,
].join(''))
}
/* Bump the package if we need to */
this._bump()
}
align(version?: string): void {
if (this._workspaces.length < 2) {
return this._debug(`No workspaces found in ${this._details}`)
}
if (! version) {
let aligned = '0.0.0'
for (const [ , version ] of this._workspaces) {
if (semver.gt(version, aligned)) aligned = version
}
version = aligned
}
this.version = version
for (const child of this._children) child.version = version
console.log(`Workspaces versions aligned to ${Y}${version}${X}`)
}
/** Write out the new package file */
async write(): Promise<void> {
assert(this._packageData, 'Updater not initialized')
/* Sort all our dependencies */
for (const type of dependencyTypes) {
const dependencies = Object.entries(this._packageData[type] || {})
if (dependencies.length) {
this._packageData[type] = dependencies
.sort(([ nameA ], [ nameB ]) => nameA.localeCompare(nameB))
.reduce((deps, [ name, version ]) => {
deps[name] = version
return deps
}, {} as Record<string, string>)
} else {
delete this._packageData[type]
}
}
const json = JSON.stringify(this._packageData, null, 2)
this._debug(`${Y}>>>`, this.packageFile, `<<<${X}\n${json}`)
await writeFile(this.packageFile, json + '\n')
for (const child of this._children) await child.write()
}
}