diff --git a/.changeset/remove-cli-kit-semver.md b/.changeset/remove-cli-kit-semver.md new file mode 100644 index 0000000000..6701aa588b --- /dev/null +++ b/.changeset/remove-cli-kit-semver.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Replace direct semver usage with compare-versions. diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 1725431d06..572895de18 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -121,6 +121,7 @@ "chalk": "5.4.1", "change-case": "4.1.2", "color-json": "3.0.5", + "compare-versions": "6.1.1", "conf": "11.0.2", "deepmerge": "4.3.1", "dotenv": "16.4.7", @@ -151,7 +152,6 @@ "open": "8.4.2", "pathe": "1.1.2", "react": "19.2.4", - "semver": "7.6.3", "stacktracey": "2.1.8", "strip-ansi": "7.1.0", "supports-hyperlinks": "3.1.0", @@ -165,7 +165,6 @@ "@types/gradient-string": "^1.1.2", "@types/lodash": "4.17.19", "@types/react": "^19.0.0", - "@types/semver": "^7.5.2", "@types/which": "3.0.4", "@vitest/coverage-istanbul": "^3.1.4", "msw": "^2.7.1", diff --git a/packages/cli-kit/src/public/node/node-package-manager.test.ts b/packages/cli-kit/src/public/node/node-package-manager.test.ts index 2e5221a75a..625bbad362 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.test.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.test.ts @@ -23,6 +23,7 @@ import { PackageManager, npmLockfile, lockfilesByManager, + versionSatisfies, } from './node-package-manager.js' import {captureOutput, exec} from './system.js' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from './fs.js' @@ -584,6 +585,26 @@ describe('checkForCachedNewVersion', () => { }) }) +describe('versionSatisfies', () => { + test.each<[boolean, string, string]>([ + [true, '1.2.3', '>=1.0.0'], + [true, '1.2.3', '<=1.2.3'], + [false, '1.2.3', '<1.2.3'], + [false, '1.2', '>=1.2.0'], + [true, '2.0.0', '<=2.0'], + [true, '2.0.1', '<=2.0'], + [false, '2.1.0', '<=2.0'], + [false, '0.0.0-snapshot', '<1.0.0'], + [false, '1.2.3-alpha.1', '<1.2.3'], + [true, '1.2.3-alpha.1', '>=1.2.3-alpha.0'], + [true, '1.2.3-alpha.1', '<=1.2.3-alpha.1'], + [true, '1.2.3-alpha.1', '<1.2.3-alpha.2'], + [false, '1.2.4-alpha.1', '>1.2.3-alpha.1'], + ])('returns %s for version %s and requirements %s', (expected, version, requirements) => { + expect(versionSatisfies(version, requirements)).toBe(expected) + }) +}) + describe('checkForNewVersion', () => { beforeEach(() => cacheClear()) afterEach(() => { diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index c2335b46d9..93719bf30a 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -8,7 +8,7 @@ import {inferPackageManagerForGlobalCLI} from './is-global.js' import {outputToken, outputContent, outputDebug} from './output.js' import {PackageVersionKey, cacheRetrieve, cacheRetrieveOrRepopulate} from '../../private/node/conf-store.js' import {parseJSON} from '../common/json.js' -import {SemVer, satisfies as semverSatisfies} from 'semver' +import {compareVersions, satisfies as versionSatisfiesRequirement} from 'compare-versions' import type {Writable} from 'stream' import type {ExecOptions} from './system.js' @@ -334,7 +334,7 @@ export async function checkForNewVersion( return undefined } - if (lastVersion && new SemVer(currentVersion).compare(lastVersion) < 0) { + if (lastVersion && compareVersions(currentVersion, lastVersion) < 0) { return lastVersion } else { return undefined @@ -351,7 +351,7 @@ export function checkForCachedNewVersion(dependency: string, currentVersion: str const cacheKey: PackageVersionKey = `npm-package-${dependency}` const lastVersion = cacheRetrieve(cacheKey)?.value - if (lastVersion && new SemVer(currentVersion).compare(lastVersion) < 0) { + if (lastVersion && compareVersions(currentVersion, lastVersion) < 0) { return lastVersion } else { return undefined @@ -365,7 +365,39 @@ export function checkForCachedNewVersion(dependency: string, currentVersion: str * @returns A boolean indicating whether the version satisfies the requirements */ export function versionSatisfies(version: string, requirements: string): boolean { - return semverSatisfies(version, requirements) + if (!semverVersionRegex.test(version)) return false + + const prereleaseBaseVersion = baseVersionForPrerelease(version) + // semver excludes prerelease candidates from normal ranges unless the range opts into that prerelease tuple. + if (prereleaseBaseVersion && !requirementsTargetPrereleaseBaseVersion(requirements, prereleaseBaseVersion)) { + return false + } + + return versionSatisfiesRequirement(version, normalizeSemverPartialRequirements(requirements)) +} + +const semverVersionRegex = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ +const prereleaseVersionRegex = /^v?(\d+)\.(\d+)\.(\d+)-[^+]+(?:\+.*)?$/ +const prereleaseRequirementRegex = /v?(\d+)\.(\d+)\.(\d+)-[0-9A-Za-z.-]+(?:\+[0-9A-Za-z.-]+)?/g +const partialMinorUpperBoundRegex = /(^|\s)<=\s*v?(\d+)\.(\d+)(?=$|\s)/g + +function baseVersionForPrerelease(version: string): string | undefined { + const prereleaseMatch = version.match(prereleaseVersionRegex) + if (!prereleaseMatch) return undefined + + return `${prereleaseMatch[1]}.${prereleaseMatch[2]}.${prereleaseMatch[3]}` +} + +function requirementsTargetPrereleaseBaseVersion(requirements: string, baseVersion: string): boolean { + return Array.from(requirements.matchAll(prereleaseRequirementRegex)).some((requirementMatch) => { + return `${requirementMatch[1]}.${requirementMatch[2]}.${requirementMatch[3]}` === baseVersion + }) +} + +function normalizeSemverPartialRequirements(requirements: string): string { + return requirements.replace(partialMinorUpperBoundRegex, (_match, prefix: string, major: string, minor: string) => { + return `${prefix}<${major}.${Number(minor) + 1}.0` + }) } /** diff --git a/packages/cli-kit/src/public/node/version.test.ts b/packages/cli-kit/src/public/node/version.test.ts index 98f3ce67a1..c8892c1ecb 100644 --- a/packages/cli-kit/src/public/node/version.test.ts +++ b/packages/cli-kit/src/public/node/version.test.ts @@ -1,5 +1,5 @@ import {captureOutput} from './system.js' -import {localCLIVersion, globalCLIVersion, isPreReleaseVersion} from './version.js' +import {localCLIVersion, globalCLIVersion, isPreReleaseVersion, isMajorVersionChange} from './version.js' import {inTemporaryDirectory} from './fs.js' import {describe, expect, test, vi} from 'vitest' @@ -89,3 +89,26 @@ describe('isPreReleaseVersion', () => { expect(isPreReleaseVersion('3.68.0')).toBe(false) }) }) + +describe('isMajorVersionChange', () => { + test('returns true when the major version changes', () => { + expect(isMajorVersionChange('3.68.0', '4.0.0')).toBe(true) + }) + + test('returns false when the major version stays the same', () => { + expect(isMajorVersionChange('3.68.0', '3.69.0')).toBe(false) + }) + + test('handles a leading v and prerelease suffix', () => { + expect(isMajorVersionChange('v3.68.0-alpha.1', '3.69.0')).toBe(false) + }) + + test('throws for partial versions', () => { + expect(() => isMajorVersionChange('3.68', '4.0.0')).toThrow('Invalid version: 3.68') + }) + + test('returns false for prerelease CLI versions', () => { + expect(isMajorVersionChange('0.0.0-snapshot', '4.0.0')).toBe(false) + expect(isMajorVersionChange('3.68.0', '0.0.0-snapshot')).toBe(false) + }) +}) diff --git a/packages/cli-kit/src/public/node/version.ts b/packages/cli-kit/src/public/node/version.ts index a28ba9e44c..06864234d8 100644 --- a/packages/cli-kit/src/public/node/version.ts +++ b/packages/cli-kit/src/public/node/version.ts @@ -1,6 +1,6 @@ import {captureOutput} from './system.js' import which from 'which' -import {satisfies, SemVer} from 'semver' +import {satisfies, validateStrict} from 'compare-versions' /** * Returns the version of the local dependency of the CLI if it's installed in the provided directory. * @@ -54,6 +54,12 @@ export function isPreReleaseVersion(version: string): boolean { return version.startsWith('0.0.0') } +function majorVersion(version: string): number { + const normalizedVersion = version.replace(/^v/, '') + if (!validateStrict(normalizedVersion)) throw new Error(`Invalid version: ${version}`) + return Number(normalizedVersion.split('.')[0]) +} + /** * Checks if there is a major version change between two versions. * Pre-release versions (0.0.0-*) are treated as not having a major version change. @@ -64,7 +70,5 @@ export function isPreReleaseVersion(version: string): boolean { */ export function isMajorVersionChange(currentVersion: string, newerVersion: string): boolean { if (isPreReleaseVersion(currentVersion) || isPreReleaseVersion(newerVersion)) return false - const currentSemVer = new SemVer(currentVersion) - const newerSemVer = new SemVer(newerVersion) - return currentSemVer.major !== newerSemVer.major + return majorVersion(currentVersion) !== majorVersion(newerVersion) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6355cc3989..79e89edd47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: color-json: specifier: 3.0.5 version: 3.0.5 + compare-versions: + specifier: 6.1.1 + version: 6.1.1 conf: specifier: 11.0.2 version: 11.0.2 @@ -444,9 +447,6 @@ importers: react: specifier: 19.2.4 version: 19.2.4 - semver: - specifier: 7.6.3 - version: 7.6.3 stacktracey: specifier: 2.1.8 version: 2.1.8 @@ -481,9 +481,6 @@ importers: '@types/react': specifier: 18.3.12 version: 18.3.12 - '@types/semver': - specifier: ^7.5.2 - version: 7.7.1 '@types/which': specifier: 3.0.4 version: 3.0.4 @@ -4210,9 +4207,6 @@ packages: '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -5078,6 +5072,9 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -13674,8 +13671,6 @@ snapshots: '@types/retry@0.12.5': {} - '@types/semver@7.7.1': {} - '@types/statuses@2.0.6': {} '@types/tinycolor2@1.4.6': {} @@ -14654,6 +14649,8 @@ snapshots: common-tags@1.8.2: {} + compare-versions@6.1.1: {} + compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13