diff --git a/packages/utils/upgrade/src/__tests__/core/version.test.ts b/packages/utils/upgrade/src/__tests__/core/version.test.ts index bc84670944d..569faae3968 100644 --- a/packages/utils/upgrade/src/__tests__/core/version.test.ts +++ b/packages/utils/upgrade/src/__tests__/core/version.test.ts @@ -1,10 +1,12 @@ +import semver from 'semver'; + import { isVersionRelease, isLatestVersion, isVersion, isSemVer, createSemverRange, - VersionRelease, + formatSemVer, } from '../../core'; describe('Version', () => { @@ -13,6 +15,8 @@ describe('Version', () => { ['5.0', false], ['5.0.0', false], ['5.0.0.0', false], + ['next', true], + ['current', true], ['latest', true], ['major', true], ['minor', true], @@ -30,6 +34,8 @@ describe('Version', () => { ['5.0', false], ['5.0.0', false], ['5.0.0.0', false], + ['next', false], + ['current', false], ['latest', true], ['major', false], ['minor', false], @@ -47,6 +53,8 @@ describe('Version', () => { ['5.0', false], ['5.0.0', true], ['5.0.0.0', false], + ['next', true], + ['current', true], ['latest', true], ['major', true], ['minor', true], @@ -81,27 +89,41 @@ describe('Version', () => { const from = '4.0.0'; const to = '6.0.0'; - const range = createSemverRange({ from, to }); + const range = createSemverRange(`>${from} <=${to}`); + + expect(range.test(from)).toBe(false); - expect(range.raw).toStrictEqual(`>${from} <=${to}`); + expect(range.test('5.0.0')).toBe(true); + expect(range.test(to)).toBe(true); }); test('Create a range to "latest"', () => { const from = '4.0.0'; - const to = VersionRelease.Latest; - const range = createSemverRange({ from, to }); + const range = createSemverRange(`>${from}`); + + expect(range.test(from)).toBe(false); + + expect(range.test('9.0.0')).toBe(true); + }); + }); - expect(range.raw).toStrictEqual(`>${from}`); + describe('Format SemVer', () => { + const version = new semver.SemVer('4.15.5'); + + test('Format to ', () => { + const formatted = formatSemVer(version, 'x'); + expect(formatted).toBe('4'); }); - test('Throw on invalid boundaries', () => { - const from = '6.0.0'; - const to = '4.0.0'; + test('Format to .', () => { + const formatted = formatSemVer(version, 'x.x'); + expect(formatted).toBe('4.15'); + }); - expect(() => createSemverRange({ from, to })).toThrowError( - `Upper boundary (${to}) must be greater than lower boundary (${from})` - ); + test('Format to ..', () => { + const formatted = formatSemVer(version, 'x.x.x'); + expect(formatted).toBe('4.15.5'); }); }); }); diff --git a/packages/utils/upgrade/src/cli/commands/upgrade.ts b/packages/utils/upgrade/src/cli/commands/upgrade.ts index cc3f214c15b..ff7875b5f1e 100644 --- a/packages/utils/upgrade/src/cli/commands/upgrade.ts +++ b/packages/utils/upgrade/src/cli/commands/upgrade.ts @@ -21,6 +21,7 @@ export const upgrade = async (options: CLIOptions) => { dryRun: options.dryRun, cwd: options.projectPath, target: options.target, + exact: options.exact, }); } catch (err) { handleError(err); diff --git a/packages/utils/upgrade/src/cli/index.ts b/packages/utils/upgrade/src/cli/index.ts index 707a4a231e7..5edaf1ee2db 100644 --- a/packages/utils/upgrade/src/cli/index.ts +++ b/packages/utils/upgrade/src/cli/index.ts @@ -6,23 +6,28 @@ import { isVersion, VersionRelease } from '../core'; import type { CLIOptions } from '../types'; -const ALLOWED_TARGETS = - 'Allowed choices are major, minor, patch, latest, or a specific version number in the form "x.x.x"'; +const RELEASES_CHOICES = Object.values(VersionRelease).join(', '); +const ALLOWED_TARGETS = `Allowed choices are ${RELEASES_CHOICES} or a specific version number in the form "x.x.x"`; program .description('Upgrade to the desired version') + .option('-p, --project-path ', 'Path to the Strapi project') .addOption( - new Option('-t, --target ', `Specify which version to upgrade to. ${ALLOWED_TARGETS}`) - .default(VersionRelease.Patch) + new Option('-t, --target ', `Specify which version to upgrade to ${ALLOWED_TARGETS}`) + .default(VersionRelease.Next) .argParser((target) => { assert(isVersion(target), new InvalidOptionArgumentError(ALLOWED_TARGETS)); return target; }) ) + .option( + '-e --exact', + 'If is in the form "x.x.x", only run the upgrade for this version', + false + ) .option('-n, --dry-run', 'Simulate the upgrade without updating any files', false) .option('-d, --debug', 'Get more logs in debug mode', false) .option('-s, --silent', "Don't log anything", false) - .option('-p, --project-path ', 'Path to the Strapi project') .action(async () => { const options = program.opts(); diff --git a/packages/utils/upgrade/src/core/transforms-loader.ts b/packages/utils/upgrade/src/core/transforms-loader.ts index 84cf85c2165..6d5a141f8fb 100644 --- a/packages/utils/upgrade/src/core/transforms-loader.ts +++ b/packages/utils/upgrade/src/core/transforms-loader.ts @@ -1,17 +1,17 @@ import * as semver from 'semver'; import * as path from 'node:path'; import assert from 'node:assert'; -import { readdirSync, statSync } from 'node:fs'; +import { readdirSync, statSync, existsSync } from 'node:fs'; -import { createSemverRange, isVersionRelease } from './version'; +import { isVersionRelease } from './version'; import * as f from './format'; -import type { Logger, Version, VersionRange, SemVer } from '.'; +import type { Logger, Version, SemVer } from '.'; import type { TransformFile, TransformFileKind } from '../types'; export interface CreateTransformsLoaderOptions { dir?: string; - range: VersionRange; + range: semver.Range; logger: Logger; } @@ -27,7 +27,7 @@ const TRANSFORM_FILE_REGEXP = new RegExp( export const createTransformsLoader = (options: CreateTransformsLoaderOptions) => { const { dir = INTERNAL_TRANSFORMS_DIR, range, logger } = options; - const semverRange = createSemverRange(range); + assert(existsSync(dir), `Invalid transforms directory provided "${dir}"`); // TODO: Maybe add some more logs regarding what folders are accepted/discarded const versions = readdirSync(dir) @@ -36,19 +36,18 @@ export const createTransformsLoader = (options: CreateTransformsLoaderOptions) = // Paths should be valid semver .filter((filePath): filePath is SemVer => semver.valid(filePath) !== null) // Should satisfy the given range - .filter((filePath) => semverRange.test(filePath)) + .filter((filePath) => range.test(filePath)) // Sort versions in ascending order .sort(semver.compare) as SemVer[]; - if (versions.length === 0) { - // TODO: Use custom upgrade errors - throw new Error(`Invalid transforms directory provided "${dir}"`); - } - const fNbFound = f.highlight(versions.length.toString()); - const fRange = f.versionRange(semverRange.raw); + const fRange = f.versionRange(range.raw); const fVersions = versions.map(f.version).join(', '); + if (versions.length === 0) { + throw new Error(`Could not find any upgrade matching the given range (${fRange})`); + } + logger.debug(`Found ${fNbFound} upgrades matching ${fRange} (${fVersions})`); // Note: We're casting the result as a SemVer since we know there is at least one item in the `versions` array @@ -100,15 +99,13 @@ export const createTransformsLoader = (options: CreateTransformsLoaderOptions) = return transformsPath; }; - const loadRange = (range: VersionRange): TransformFile[] => { + const loadRange = (range: semver.Range): TransformFile[] => { const paths: TransformFile[] = []; - const semverRange = createSemverRange(range); - - logger.debug(`Loading transforms matching ${f.versionRange(semverRange.raw)}`); + logger.debug(`Loading transforms matching ${f.versionRange(range.raw)}`); for (const version of versions) { - const isInRange = semverRange.test(version); + const isInRange = range.test(version); if (isInRange) { const transformsForVersion = load(version); diff --git a/packages/utils/upgrade/src/core/version-parser.ts b/packages/utils/upgrade/src/core/version-parser.ts index 52b48ef3c84..b72bb8cda42 100644 --- a/packages/utils/upgrade/src/core/version-parser.ts +++ b/packages/utils/upgrade/src/core/version-parser.ts @@ -1,17 +1,24 @@ import semver from 'semver'; -import assert from 'node:assert'; -import { isLatestVersion, isSemVer, isVersionRelease, VersionRelease } from './version'; +import { + createSemverRange, + formatSemVer, + isNextVersion, + isSemVer, + isVersionRelease, + VersionRelease, +} from './version'; import type { SemVer, Version } from './version'; export interface VersionParser { - current: string; setAvailable(versions: SemVer[] | null): VersionParser; nextMajor(): SemVer | undefined; nextMinor(): SemVer | undefined; nextPatch(): SemVer | undefined; latest(): SemVer | undefined; + current(): SemVer | undefined; + next(): SemVer | undefined; exact(version: SemVer): SemVer | undefined; search(version: Version): SemVer | undefined; } @@ -30,10 +37,6 @@ export const createVersionParser: CreateVersionParser = (current) => { }; return { - get current(): string { - return state.current.raw; - }, - setAvailable(versions: SemVer[] | null) { state.available = versions !== null ? versions.map((v) => new semver.SemVer(v)) : null; @@ -56,61 +59,107 @@ export const createVersionParser: CreateVersionParser = (current) => { return this.search(VersionRelease.Latest); }, + next() { + return this.search(VersionRelease.Next); + }, + + current() { + return this.search(VersionRelease.Current); + }, + exact(version: SemVer) { return this.search(version); }, search(version: Version) { - if (!state.available) { + const { current, available } = state; + const currentAsString = current.raw as SemVer; + + if (!available) { return undefined; } - let versionFilter: (v: semver.SemVer) => boolean = () => false; + let range: semver.Range; if (isSemVer(version)) { - assert( - state.current.compare(version) === -1, - `The given version should be greater than the current one (${state.current.raw}>${version})` - ); - // {current} > {v} AND {v} <= {version} - versionFilter = (v) => v.compare(state.current) === 1 && v.compare(version) <= 0; + range = semver.gt(version, current) + ? // If target > current, return a range + createSemverRange(`>${currentAsString} <=${version}`) + : // Else, return an exact match + createSemverRange(`=${version}`); } if (isVersionRelease(version)) { - versionFilter = (v) => { - switch (version) { - case VersionRelease.Latest: - // match any version that is greater than the current one - return v.compare(state.current) === 1; - case VersionRelease.Major: - // match any version which major release is greater than the current one - return v.major > state.current.major; - case VersionRelease.Minor: - // match any version which minor release is greater than the current one - return v.minor > state.current.minor; - case VersionRelease.Patch: - // match any version which patch release is greater than the current one - return v.patch > state.current.patch; - default: - throw new Error(`Internal error: Invalid version release found: ${version}`); + switch (version) { + /** + * Only accept the same version as the current one + */ + case VersionRelease.Current: { + range = createSemverRange(`=${currentAsString}`); // take exactly this version + break; } - }; + /** + * Accept any version greater than the current one + */ + case VersionRelease.Latest: + case VersionRelease.Next: { + range = createSemverRange(`>${currentAsString}`); + break; + } + /** + * Accept any version where + * - The overall version is greater than the current one + * - The major version is the same or +1 + */ + case VersionRelease.Major: { + const nextMajor = formatSemVer(current.inc('major'), 'x'); + range = createSemverRange(`>${currentAsString} <=${nextMajor}`); + break; + } + /** + * Accept any version where + * - The overall version is greater than the current one + * - The major version is the same + * - The minor version is either the same or +1 + */ + case VersionRelease.Minor: { + const nextMinor = formatSemVer(current.inc('minor'), 'x.x'); + range = createSemverRange(`>${currentAsString} <=${nextMinor}`); + break; + } + /** + * Accept any version where + * - The overall version is greater than the current one + * - The major version is the same + * - The minor version is the same + * - The patch version is the same + 1 + */ + case VersionRelease.Patch: { + const nextPatch = formatSemVer(current.inc('patch'), 'x.x.x'); + range = createSemverRange(`>${currentAsString} <=${nextPatch}`); + break; + } + default: + throw new Error(`Internal error: Invalid version release found: ${version}`); + } } - const matches = state.available + const matches = available // Removes invalid versions - .filter(versionFilter) + .filter((semVer) => range.test(semVer)) // Sort from the oldest to the newest .sort(semver.compare); const nearest = matches.at(0); const latest = matches.at(-1); - // TODO: In the following scenario: target=major, current=4.15.4, available=[4.16.0, 5.0.0, 5.2.0, 6.3.0] - // We might want to target 5.2.0 (currently, it'll return 5.0.0) - const target = isSemVer(version) || isLatestVersion(version) ? latest : nearest; + if (!nearest || !latest) { + return undefined; + } + + const match = isNextVersion(version) ? nearest : latest; - return target?.raw as SemVer | undefined; + return match?.raw as SemVer; }, }; }; diff --git a/packages/utils/upgrade/src/core/version.ts b/packages/utils/upgrade/src/core/version.ts index 49d1b0c3b74..abfa398e161 100644 --- a/packages/utils/upgrade/src/core/version.ts +++ b/packages/utils/upgrade/src/core/version.ts @@ -1,9 +1,11 @@ import * as semver from 'semver'; -import assert from 'node:assert'; export type SemVer = `${number}.${number}.${number}`; +export type LooseSemVer = `${number}` | `${number}.${number}` | `${number}.${number}.${number}`; export enum VersionRelease { + Current = 'current', + Next = 'next', Latest = 'latest', Major = 'major', Minor = 'minor', @@ -12,10 +14,16 @@ export enum VersionRelease { export type Version = SemVer | VersionRelease; -export interface VersionRange { - from: SemVer; - to: Version; -} +type GtOp = '>' | '>='; +type LtOp = '<' | '<='; +type EqOp = '='; + +export type VersionRangeAsString = + | LooseSemVer + | `${GtOp}${LooseSemVer}` + | `${LtOp}${LooseSemVer}` + | `${EqOp}${LooseSemVer}` + | `${GtOp}${LooseSemVer} ${LtOp}${LooseSemVer}`; export const isVersionRelease = (version: string): version is VersionRelease => { return Object.values(VersionRelease).includes(version); @@ -25,10 +33,31 @@ export const isLatestVersion = (str: string): str is VersionRelease.Latest => { return str === VersionRelease.Latest; }; +export const isNextVersion = (str: string): str is VersionRelease.Next => { + return str === VersionRelease.Next; +}; + +export const isCurrentVersion = (str: string): str is VersionRelease.Current => { + return str === VersionRelease.Current; +}; + export const isVersion = (str: string): str is Version => { return isVersionRelease(str) || isSemVer(str); }; +export const formatSemVer = ( + version: semver.SemVer, + format: 'x' | 'x.x' | 'x.x.x' +): LooseSemVer => { + const { major, minor, patch } = version; + const tokens = [major, minor, patch]; + + return format + .split('.') + .map((_, i) => tokens[i]) + .join('.') as LooseSemVer; +}; + export const isSemVer = (str: string): str is SemVer => { const tokens = str.split('.'); return ( @@ -37,19 +66,6 @@ export const isSemVer = (str: string): str is SemVer => { ); }; -export const createSemverRange = (range: VersionRange): semver.Range => { - let semverRange = `>${range.from}`; - - // Add the upper boundary if range.to is different from 'latest' - if (!isLatestVersion(range.to)) { - // Make sure range.from > range.to - assert( - semver.compare(range.from, range.to) === -1, - `Upper boundary (${range.to}) must be greater than lower boundary (${range.from})` - ); - - semverRange += ` <=${range.to}`; - } - - return new semver.Range(semverRange); +export const createSemverRange = (range: VersionRangeAsString): semver.Range => { + return new semver.Range(range); }; diff --git a/packages/utils/upgrade/src/tasks/upgrade.ts b/packages/utils/upgrade/src/tasks/upgrade.ts index 970027c7ca6..180292b06c3 100644 --- a/packages/utils/upgrade/src/tasks/upgrade.ts +++ b/packages/utils/upgrade/src/tasks/upgrade.ts @@ -2,8 +2,8 @@ import ora from 'ora'; import chalk from 'chalk'; import semver from 'semver'; import assert from 'node:assert'; +import path from 'node:path'; -import type { RunnerConfiguration, VersionRange } from '../core'; import { createProjectLoader, createSemverRange, @@ -11,21 +11,36 @@ import { createTransformsLoader, createTransformsRunner, createVersionParser, - f, - isLatestVersion, isSemVer, - isVersionRelease, + f, VersionRelease, } from '../core'; + +import type { RunnerConfiguration } from '../core'; import type { Report, RunReports, TaskOptions } from '../types'; import { isCleanGitRepo } from '../core/requirements/is-clean-git-repo'; export const upgrade = async (options: TaskOptions) => { - const { logger, dryRun = false, cwd = process.cwd(), target = VersionRelease.Minor } = options; - const timer = createTimer(); + + const { logger, dryRun = false, exact = false, target = VersionRelease.Minor } = options; + + // Make sure we're resolving the correct working directory based on the given input + const cwd = path.resolve(options.cwd ?? process.cwd()); + + const isTargetValidSemVer = isSemVer(target); + const isExactModeActivated = exact && isTargetValidSemVer; + const fTarget = f.version(target); + if (exact && !isExactModeActivated) { + logger.warn(`Exact mode is enabled but the target is not a SemVer (${fTarget}), ignoring...`); + } + + if (isExactModeActivated) { + logger.debug(`Exact mode is activated for ${fTarget}`); + } + logger.debug(`Setting the targeted version to: ${fTarget}`); const projectLoader = createProjectLoader({ cwd, logger }); @@ -35,24 +50,24 @@ export const upgrade = async (options: TaskOptions) => { logger.info(`The current project's Strapi version is ${fCurrentVersion}`); - // If the given target is older than the current Strapi version, then abort - if (isSemVer(target)) { + // If exact mode is disabled and the given target is older than the current Strapi version, then abort + if (isTargetValidSemVer && !isExactModeActivated) { assert( - semver.compare(project.strapiVersion, target) === -1, - `The target (${fTarget}) should be greater than the current project version (${fCurrentVersion}).` + semver.gte(target, project.strapiVersion), + `When targeting a version lower than the current one (${fTarget} < ${fCurrentVersion}), "exact" mode should be enabled.` ); } + const transformsRange = isExactModeActivated + ? createSemverRange(`=${target}`) + : createSemverRange(`>=${project.strapiVersion}`); // check if the repo is clean // TODO change force default to false when we add the force option to the CLI await isCleanGitRepo({ cwd, logger, force: false, confirm: options.confirm }); - // Create a version range for ">{current}" - const range: VersionRange = { from: project.strapiVersion, to: VersionRelease.Latest }; - // TODO: In the future, we should allow loading transforms from the user app (custom transforms) // e.g: const userTransformsDir = path.join(cwd, 'transforms'); - const transformsLoader = createTransformsLoader({ logger, range }); + const transformsLoader = createTransformsLoader({ logger, range: transformsRange }); const versionParser = createVersionParser(project.strapiVersion) // Indicates the available versions to the parser @@ -62,27 +77,20 @@ export const upgrade = async (options: TaskOptions) => { const matchedVersion = versionParser.search(target); if (matchedVersion) { - const fMatchedVersion = f.version(matchedVersion); + const isTargetingCurrent = matchedVersion === project.strapiVersion; - // The upgrade range should contain all the upgrades between the current version and the matched one - const upgradeRange: VersionRange = { - from: project.strapiVersion, - to: matchedVersion, - }; + const upgradeRange = + isExactModeActivated || isTargetingCurrent + ? createSemverRange(`=${matchedVersion}`) + : createSemverRange(`>${project.strapiVersion} <=${matchedVersion}`); - // Latest - if (isLatestVersion(target)) { - logger.info(`The ${fTarget} upgrade available is ${fMatchedVersion}`); - } - // Major, Minor, Patch - else if (isVersionRelease(target)) { - logger.info(`Latest ${fTarget} upgrade is ${fMatchedVersion}`); - } - // X.X.X - else { - const rawVersionRange = { from: project.strapiVersion, to: target }; - const fRawVersionRange = f.versionRange(createSemverRange(rawVersionRange).raw); - logger.info(`Latest available upgrade for ${fRawVersionRange} is ${fMatchedVersion}`); + const fMatchedVersion = f.version(matchedVersion); + const fUpgradeRange = f.versionRange(upgradeRange.raw); + + if (isTargetValidSemVer) { + logger.info(`Targeting ${fMatchedVersion} using ${fUpgradeRange}`); + } else { + logger.info(`Targeting ${fMatchedVersion} (${fTarget}) using ${fUpgradeRange}`); } const transformFiles = transformsLoader.loadRange(upgradeRange); @@ -93,10 +101,15 @@ export const upgrade = async (options: TaskOptions) => { .map((v) => f.version(v)) .join(' -> '); - logger.debug( - `Upgrading from ${fCurrentVersion} to ${fMatchedVersion} with the following plan: ${fUpgradePlan}` - ); - logger.info(`Preparing the upgrade (${fUpgradePlan})`); + if (isExactModeActivated) { + logger.debug(`Running the ${fMatchedVersion} upgrade ("exact" mode enabled)`); + logger.info(`Preparing the ${fMatchedVersion} upgrade...`); + } else { + logger.debug( + `Upgrading from ${fCurrentVersion} to ${fMatchedVersion} with the following plan: ${fUpgradePlan}` + ); + logger.info(`Preparing the ${fMatchedVersion} upgrade: ${fUpgradePlan}`); + } assert( transformFiles.length > 0, @@ -148,9 +161,7 @@ export const upgrade = async (options: TaskOptions) => { logger.raw(f.reports(reports)); } else { - logger.debug( - `It seems like the current version (${fCurrentVersion}) is the latest major upgrade available` - ); + logger.debug(`The current version (${fCurrentVersion}) is the latest upgrade (${fTarget})`); logger.info(chalk.bold('Already up-to-date')); } diff --git a/packages/utils/upgrade/src/types.ts b/packages/utils/upgrade/src/types.ts index c2978dd713d..f862cc613fd 100644 --- a/packages/utils/upgrade/src/types.ts +++ b/packages/utils/upgrade/src/types.ts @@ -1,19 +1,19 @@ import type { Logger, SemVer, Version } from './core'; export interface CLIOptions { - // TODO: Add back the version option when we handle targeting specific versions - // NOTE: For now we can only accept major upgrades & allow minors and patches in future releases target?: Version; projectPath?: string; dryRun: boolean; silent: boolean; debug: boolean; + exact: boolean; } export interface TaskOptions { confirm?: (message: string) => Promise | Promise | boolean | undefined; cwd?: string; dryRun?: boolean; + exact?: boolean; target?: Version; logger: Logger; }