UNPKG

@syncify/update

Version:

Version comparison and increment information for packages on the NPM Registry

370 lines (318 loc) 10.2 kB
import { kill } from '@syncify/kill'; /** * Represents the result of comparing two semantic version strings */ interface VersionUpdate { /** * The type of version change between the current and registry versions. * Indicates the level of version difference: * * > `major` - _Breaking change or significant update_ * > * > `minor` - _New features that are backwards-compatible_ * > * > `patch` - _Bug fixes or very minor updates_ */ change: 'major' | 'minor' | 'patch'; /** * Describes the change in release channel, showing the transition between release types. * * > `alpha → beta` * > * > `latest → alpha` * > * > `beta → latest` */ bump: string; /** * Whether or not the version increment applied a pre-release stage bump. When `true` * the release suffix number changed, otherwise `false`. * * @example * '1.0.0-beta.1' > '1.0.0-beta.2' = true * '1.0.0-beta.1' > '1.2.0-beta.1' = false */ step: boolean; /** * The release channel of the new (registry) version (current release stage) * * > `latest` - _version increment on non suffixed version, e.g: 1.1.0 > 1.2.0_ * > * > `rc`- _version increment on an release candidate, e.g: 1.0.0-rc.1 > 1.1.0-rc.1_ * > * > `beta`- _version increment on an beta release, e.g: 1.0.0-beta.1 > 1.1.0-beta.1_ * > * > `alpha` _version increment on an alpha release, e.g: 1.0.0-alpha.1 > 1.1.0-alpha.1_ */ release: string; /** * Indicates if the version change is considered breaking. Returns `true` if the change * introduces potential backwards-incompatible modifications. Whenever stable `major` changes * increment, this value will be `true`, otherwise this will be `false`, while adhering to the * below determinations when pre-release suffix identifiers change: * * @example * // Breaking is true when: * '1.0.0-alpha.1' > '1.0.0-beta.1' = true * '1.0.0-beta.1' > '1.0.0-rc.1' = true * '1.0.0' > '2.0.0' = true * '1.0.0-rc.1' > '1.0.0-rc.2' = true * * // Breaking is false when: * '1.0.0-rc.1' > '1.0.0' = false * '1.1.0' > '1.2.0' = false */ breaking: boolean; /** * The original current version string * * @example * '1.2.0' */ current: string; /** * The new version string from the registry * * @example * '1.2.1' */ registry: string; /** * Detailed parsing information for both current and registry versions. * Provides granular breakdown of version components returning `number` * types for each semantic modulas. */ parse: { /** * Parsing details for the current version. Breaks down the current version into its semantic components */ current: { /** Major version number */ major: number; /** Minor version number */ minor: number; /** Patch version number */ patch: number; /** Release channel of the current version */ release: string; /** * Pre-release stage number. This will be `null` if stable increment or undefined. * * @example * '1.0.0-beta.1' > 1 * '1.0.0-beta.2' > 2 * '1.0.0-beta' > 0 * '1.0.0' > null */ stage: number; }; /** * Parsing details for the registry version. Breaks down the registry version into its semantic components */ registry: { /** Major version number */ major: number; /** Minor version number */ minor: number; /** Patch version number */ patch: number; /** Release channel of the registry version */ release: string; /** * Pre-release stage number. This will be `null` if stable increment or undefined. * * @example * '1.0.0-beta.1' > 1 * '1.0.0-beta.2' > 2 * '1.0.0-beta' > 0 * '1.0.0' > null */ stage: number; }; }; } interface UpdateOptions { /** * Target a specific version tag on the package. * * @default * 'latest' */ tag?: string; /** * Pre-release priorities identifier order. Customise how * suffixes are handled and determine the order of importance * where `1` is considered oldest. * * @default * { alpha: 1, beta: 2, rc: 3 } * * @example * { * alpha: 1, // 1.0.0-alpha * beta: 2, // 1.0.0-beta * rc: 3 // 1.0.0-rc * } * */ priorities?: Record<string, number> } // Precompile regex for performance const VERSION_REGEX = /^(\d+)\.(\d+)\.(\d+)(-([a-z]+)(?:\.(\d+))?)?$/i; function compare (a: string, b: string, prio?: Record<string, number>): false | VersionUpdate { // Version parsing with error handling const parseVersion = (version: string) => { const match = version.match(VERSION_REGEX); if (!match) { throw new Error(`Invalid version format: ${version}`); } return { parts: [ parseInt(match[1], 10), // major parseInt(match[2], 10), // minor parseInt(match[3], 10) // patch ], release: match[5] || 'latest', preRelease: match[5] ? `${match[5]}${match[6] ? `.${match[6]}` : ''}` : undefined, stage: match[5] ? parseInt(match[6] || '0', 10) : null }; }; // Comprehensive pre-release comparison with custom priorities const comparePreRelease = (preA: string | undefined, preB: string | undefined): { comparison: number, step: boolean } => { if (!preA && !preB) return { comparison: 0, step: false }; if (!preA) return { comparison: 1, step: false }; if (!preB) return { comparison: -1, step: false }; const [ typeA, numA = '0' ] = preA.split('.'); const [ typeB, numB = '0' ] = preB.split('.'); const priorityA = prio[typeA.toLowerCase()] || 0; const priorityB = prio[typeB.toLowerCase()] || 0; // Compare release channel priority first if (priorityA !== priorityB) return { comparison: priorityA - priorityB, step: false }; // Compare pre-release numbers const numericComparison = Number(numA) - Number(numB); return { comparison: numericComparison, step: numericComparison !== 0 }; }; // Memoize parsing to avoid re-parsing the same versions const parseA = parseVersion(a); const parseB = parseVersion(b); // Comprehensive version comparison const compareVersionParts = () => { // Compare major, minor, patch versions for (let i = 0; i < 3; i++) { const diff = parseA.parts[i] - parseB.parts[i]; if (diff !== 0) return i === 0 ? 'major' : i === 1 ? 'minor' : 'patch'; } // If version parts are identical, compare pre-release const preReleaseResult = comparePreRelease(parseA.preRelease, parseB.preRelease); // Pre-release difference is considered a patch-level change if (preReleaseResult.comparison !== 0) return 'patch'; return 'patch'; }; if (parseA.preRelease === parseB.preRelease) { if (parseA.parts.every((part, i) => part === parseB.parts[i])) return false; // No difference // Check if current version is higher and throw if (Number(parseA.parts.join('')) > Number(parseB.parts.join(''))) { throw new Error(`Current version is greater than registry version: ${a} > ${b}`); } } // Determine change type const change = compareVersionParts(); // Pre-release comparison for breaking and pre-release changes const preReleaseResult = comparePreRelease(parseA.preRelease, parseB.preRelease); // Determine if it's a breaking change const breaking = change === 'major' || ((prio[parseA.release.toLowerCase()] || 0) < (prio[parseB.release.toLowerCase()] || 0)) || (preReleaseResult.comparison > 0) || (parseB.stage > parseA.stage); // Determine bump type const bump = parseA.preRelease && parseB.preRelease ? parseA.release === parseB.release ? `${parseA.release}.${parseA.stage}${parseB.release}.${parseB.stage}` : `${parseA.release}${parseB.release}` : parseA.preRelease ? `${parseA.release} → latest` : `latest → ${parseB.release}`; return { change, bump, release: parseB.release, breaking, step: preReleaseResult.step, current: a, registry: b, parse: { get current () { return { major: parseA.parts[0], minor: parseA.parts[1], patch: parseA.parts[2], release: parseA.release, stage: parseA.stage }; }, get registry () { return { major: parseB.parts[0], minor: parseB.parts[1], patch: parseB.parts[2], release: parseB.release, stage: parseB.stage }; } } }; } async function get (name: string): Promise<string> { const ac = new AbortController(); kill(() => ac.abort()); try { const request = await fetch(`https://registry.npmjs.org/${name}`, { signal: ac.signal }); const json = await request.json(); return json.version; } catch (_) { return null; } } /** * Queries the NPM register, compares version numbers. When versions match, * a boolean `false` is returned, whereas a newer version returns an object * with information regarding the version increment applied. * * > See the {@link VersionUpdate} interface for context * * @param name * The NPM Package name * * @param version * The version number to compare, this will be the local version * * @param options * Optionally provide additional control settings * * @example * import update from '@syncify/update'; * * const version = await update('@syncify/cli', '1.0.0', { * tag: 'latest', * priorities: { alpha: 1, beta: 2, rc: 3 } * }); * * console.log(version); */ async function update ( name: string, version: string, { tag = 'latest', priorities = undefined }: UpdateOptions = {} ) { if (!process?.stdout?.isTTY) return; // Probably piping stdout const registry = await get(`${name}/${tag}`); if (registry === null) return false; return compare(version, registry, { alpha: 1, beta: 2, rc: 3, ...priorities }); }; export default update;