diff --git a/packages/utils/upgrade/src/cli/commands/upgrade.ts b/packages/utils/upgrade/src/cli/commands/upgrade.ts index cd419651fc..c54c4ae4fb 100644 --- a/packages/utils/upgrade/src/cli/commands/upgrade.ts +++ b/packages/utils/upgrade/src/cli/commands/upgrade.ts @@ -2,6 +2,7 @@ import prompts from 'prompts'; import { InvalidArgumentError, Option } from 'commander'; import type { Command } from 'commander'; + import { loggerFactory } from '../../modules/logger'; import { Version, isLiteralSemVer, isValidSemVer, semVerFactory } from '../../modules/version'; import { handleError } from '../errors'; @@ -71,6 +72,12 @@ export const register = (program: Command) => { }); }; + // upgrade latest + addReleaseUpgradeCommand( + Version.ReleaseType.Latest, + 'Upgrade to the latest available version of Strapi' + ); + // upgrade major addReleaseUpgradeCommand( Version.ReleaseType.Major, diff --git a/packages/utils/upgrade/src/cli/errors.ts b/packages/utils/upgrade/src/cli/errors.ts index ab503cc892..4ca6d3fcd5 100644 --- a/packages/utils/upgrade/src/cli/errors.ts +++ b/packages/utils/upgrade/src/cli/errors.ts @@ -1,6 +1,13 @@ import chalk from 'chalk'; +import { AbortedError } from '../modules/error'; + export const handleError = (err: unknown, isSilent: boolean) => { + // If the upgrade process has been aborted, exit silently + if (err instanceof AbortedError) { + process.exit(0); + } + if (!isSilent) { console.error( chalk.red(`[ERROR]\t[${new Date().toISOString()}]`), diff --git a/packages/utils/upgrade/src/modules/error/utils.ts b/packages/utils/upgrade/src/modules/error/utils.ts index 014f8cb1c7..ae448a599c 100644 --- a/packages/utils/upgrade/src/modules/error/utils.ts +++ b/packages/utils/upgrade/src/modules/error/utils.ts @@ -19,6 +19,12 @@ export class NPMCandidateNotFoundError extends Error { } } +export class AbortedError extends Error { + constructor(message: string = 'Upgrade aborted') { + super(message); + } +} + export const unknownToError = (e: unknown): Error => { if (e instanceof Error) { return e; diff --git a/packages/utils/upgrade/src/modules/upgrader/types.ts b/packages/utils/upgrade/src/modules/upgrader/types.ts index 3e4854964a..d5be46ec3c 100644 --- a/packages/utils/upgrade/src/modules/upgrader/types.ts +++ b/packages/utils/upgrade/src/modules/upgrader/types.ts @@ -1,9 +1,15 @@ +import type { NPM } from '../npm'; +import type { AppProject } from '../project'; import type { Version } from '../version'; import type { Requirement } from '../requirement'; import type { Logger } from '../logger'; import type { ConfirmationCallback } from '../common/types'; export interface Upgrader { + getNPMPackage(): NPM.Package; + getProject(): AppProject; + getTarget(): Version.SemVer; + setTarget(target: Version.SemVer): this; setRequirements(requirements: Requirement.Requirement[]): this; setLogger(logger: Logger): this; @@ -14,6 +20,8 @@ export interface Upgrader { dry(enabled?: boolean): this; onConfirm(callback: ConfirmationCallback | null): this; + confirm(message: string): Promise; + addRequirement(requirement: Requirement.Requirement): this; upgrade(): Promise; diff --git a/packages/utils/upgrade/src/modules/upgrader/upgrader.ts b/packages/utils/upgrade/src/modules/upgrader/upgrader.ts index 77195e1610..b644090025 100644 --- a/packages/utils/upgrade/src/modules/upgrader/upgrader.ts +++ b/packages/utils/upgrade/src/modules/upgrader/upgrader.ts @@ -57,6 +57,18 @@ export class Upgrader implements UpgraderInterface { this.confirmationCallback = null; } + getNPMPackage(): NPM.Package { + return this.npmPackage; + } + + getProject(): AppProject { + return this.project; + } + + getTarget(): Version.SemVer { + return semVerFactory(this.target.raw); + } + setRequirements(requirements: Requirement.Requirement[]) { this.requirements = requirements; return this; @@ -172,6 +184,14 @@ export class Upgrader implements UpgraderInterface { return successReport(); } + async confirm(message: string): Promise { + if (typeof this.confirmationCallback !== 'function') { + return true; + } + + return this.confirmationCallback(message); + } + private async checkRequirements( requirements: Requirement.Requirement[], context: Requirement.TestContext @@ -292,10 +312,13 @@ export class Upgrader implements UpgraderInterface { private async runCodemods(range: Version.Range): Promise { const codemodRunner = codemodRunnerFactory(this.project, range); + codemodRunner.dry(this.isDry); + if (this.logger) { codemodRunner.setLogger(this.logger); } + await codemodRunner.run(); } } diff --git a/packages/utils/upgrade/src/modules/version/__tests__/range.test.ts b/packages/utils/upgrade/src/modules/version/__tests__/range.test.ts index 0e0e601d6a..0acdedc0d9 100644 --- a/packages/utils/upgrade/src/modules/version/__tests__/range.test.ts +++ b/packages/utils/upgrade/src/modules/version/__tests__/range.test.ts @@ -22,6 +22,30 @@ describe('Version Utilities', () => { expect(range.raw).toBe('>1.0.0 <=2'); }); + it('should create a range for Minor release type', () => { + const currentVersion = new semver.SemVer('1.0.0'); + + const range = rangeFromReleaseType(currentVersion, Version.ReleaseType.Minor); + expect(range).toBeInstanceOf(semver.Range); + expect(range.raw).toBe('>1.0.0 <2.0.0'); + }); + + it('should create a range for Patch release type', () => { + const currentVersion = new semver.SemVer('1.0.0'); + + const range = rangeFromReleaseType(currentVersion, Version.ReleaseType.Patch); + expect(range).toBeInstanceOf(semver.Range); + expect(range.raw).toBe('>1.0.0 <1.1.0'); + }); + + it('should create a range for Latest release type', () => { + const currentVersion = new semver.SemVer('1.0.0'); + + const range = rangeFromReleaseType(currentVersion, Version.ReleaseType.Latest); + expect(range).toBeInstanceOf(semver.Range); + expect(range.raw).toBe('>1.0.0'); + }); + it('should throw for unsupported release types', () => { const currentVersion = new semver.SemVer('1.0.0'); expect(() => diff --git a/packages/utils/upgrade/src/modules/version/__tests__/semver.test.ts b/packages/utils/upgrade/src/modules/version/__tests__/semver.test.ts index 9f43cdb97c..485e2694a1 100644 --- a/packages/utils/upgrade/src/modules/version/__tests__/semver.test.ts +++ b/packages/utils/upgrade/src/modules/version/__tests__/semver.test.ts @@ -43,6 +43,7 @@ describe('Version Utilities', () => { expect(isSemVerReleaseType(Version.ReleaseType.Major)).toBe(true); expect(isSemVerReleaseType(Version.ReleaseType.Minor)).toBe(true); expect(isSemVerReleaseType(Version.ReleaseType.Patch)).toBe(true); + expect(isSemVerReleaseType(Version.ReleaseType.Latest)).toBe(true); }); it('should return false for invalid release types', () => { diff --git a/packages/utils/upgrade/src/modules/version/range.ts b/packages/utils/upgrade/src/modules/version/range.ts index 0fba26100e..889b8236a7 100644 --- a/packages/utils/upgrade/src/modules/version/range.ts +++ b/packages/utils/upgrade/src/modules/version/range.ts @@ -9,12 +9,16 @@ export const rangeFactory = (range: string): Version.Range => { export const rangeFromReleaseType = (current: Version.SemVer, identifier: Version.ReleaseType) => { switch (identifier) { + case Version.ReleaseType.Latest: { + // Match anything greater than the current version + return rangeFactory(`>${current.raw}`); + } case Version.ReleaseType.Major: { // For example, 4.15.4 returns 5.0.0 const nextMajor = semVerFactory(current.raw).inc('major'); - // Using only the major version as upper limit allows any minor, - // patch or build version to be taken in the range. + // Using only the major version as the upper limit allows any minor, + // patch, or build version to be taken in the range. // // For example, if the current version is "4.15.4", incrementing the // major version would result in "5.0.0". diff --git a/packages/utils/upgrade/src/modules/version/types.ts b/packages/utils/upgrade/src/modules/version/types.ts index 82fb9e48f8..d1c27b9d99 100644 --- a/packages/utils/upgrade/src/modules/version/types.ts +++ b/packages/utils/upgrade/src/modules/version/types.ts @@ -10,7 +10,10 @@ export type LiteralSemVer = `${Version}.${Version}.${Version}`; export type { SemVer, Range } from 'semver'; export enum ReleaseType { + // Classic Major = 'major', Minor = 'minor', Patch = 'patch', + // Other + Latest = 'latest', } diff --git a/packages/utils/upgrade/src/tasks/__tests__/codemods.test.ts b/packages/utils/upgrade/src/tasks/__tests__/codemods.test.ts index 134e67fff5..80ccac66b3 100644 --- a/packages/utils/upgrade/src/tasks/__tests__/codemods.test.ts +++ b/packages/utils/upgrade/src/tasks/__tests__/codemods.test.ts @@ -30,7 +30,7 @@ describe('codemods task', () => { target: Version.ReleaseType.Major, logger, dry: false, - selectCodemods: (options) => Promise.resolve(options), + selectCodemods: (options: T) => Promise.resolve(options), }; (codemodRunnerFactory as jest.Mock).mockReturnValue({ diff --git a/packages/utils/upgrade/src/tasks/codemods/utils.ts b/packages/utils/upgrade/src/tasks/codemods/utils.ts index ea803cd54b..319d12f084 100644 --- a/packages/utils/upgrade/src/tasks/codemods/utils.ts +++ b/packages/utils/upgrade/src/tasks/codemods/utils.ts @@ -18,6 +18,8 @@ export const getRangeFromTarget = ( const { major, minor, patch } = currentVersion; switch (target) { + case Version.ReleaseType.Latest: + throw new Error("Can't use to create a codemods range: not implemented"); case Version.ReleaseType.Major: return rangeFactory(`${major}`); case Version.ReleaseType.Minor: diff --git a/packages/utils/upgrade/src/tasks/upgrade/prompts/index.ts b/packages/utils/upgrade/src/tasks/upgrade/prompts/index.ts new file mode 100644 index 0000000000..d6c0d24fc6 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/prompts/index.ts @@ -0,0 +1 @@ +export * from './latest'; diff --git a/packages/utils/upgrade/src/tasks/upgrade/prompts/latest.ts b/packages/utils/upgrade/src/tasks/upgrade/prompts/latest.ts new file mode 100644 index 0000000000..2b20e2d6a3 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/prompts/latest.ts @@ -0,0 +1,62 @@ +import { AbortedError } from '../../../modules/error'; +import * as f from '../../../modules/format'; + +import { rangeFactory, semVerFactory, Version } from '../../../modules/version'; + +import type { Upgrader } from '../../../modules/upgrader'; +import type { UpgradeOptions } from '../types'; + +/** + * Handles the upgrade prompts when using the latest tag. + * + * - checks if an upgrade involves a major bump, warning and asking for user confirmation before proceeding + */ +export const latest = async (upgrader: Upgrader, options: UpgradeOptions) => { + // Exit if the upgrade target isn't the latest tag + if (options.target !== Version.ReleaseType.Latest) { + return; + } + + // Retrieve utilities from the upgrader instance + const npmPackage = upgrader.getNPMPackage(); + const target = upgrader.getTarget(); + const project = upgrader.getProject(); + + const { strapiVersion: current } = project; + + // Pre-formatted strings used in logs + const fTargetMajor = f.highlight(`v${target.major}`); + const fCurrentMajor = f.highlight(`v${current.major}`); + + const fTarget = f.version(target); + const fCurrent = f.version(current); + + // Flags + const isMajorUpgrade = target.major > current.major; + + // Handle potential major upgrade, warns, and asks for confirmation to proceed + if (isMajorUpgrade) { + options.logger.warn( + `Detected a major upgrade for the "${f.highlight(Version.ReleaseType.Latest)}" tag: ${fCurrent} > ${fTarget}` + ); + + // Find the latest release in between the current one and the next major + const newerPackageRelease = npmPackage + .findVersionsInRange(rangeFactory(`>${current.raw} <${target.major}`)) + .at(-1); + + // If the project isn't on the latest version for the current major, emit a warning + if (newerPackageRelease) { + const fLatest = f.version(semVerFactory(newerPackageRelease.version)); + options.logger.warn( + `It's recommended to first upgrade to the latest version of ${fCurrentMajor} (${fLatest}) before upgrading to ${fTargetMajor}.` + ); + } + + const proceedAnyway = await upgrader.confirm(`I know what I'm doing. Proceed anyway!`); + + if (!proceedAnyway) { + throw new AbortedError(); + } + } +}; diff --git a/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts b/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts index 0afd76a927..c6366e36e4 100644 --- a/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts +++ b/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts @@ -8,14 +8,16 @@ import { constants as upgraderConstants, upgraderFactory } from '../../modules/u import { Version } from '../../modules/version'; import * as requirements from './requirements'; +import * as prompts from './prompts'; import type { UpgradeOptions } from './types'; +import type { Upgrader } from '../../modules/upgrader'; export const upgrade = async (options: UpgradeOptions) => { const timer = timerFactory(); const { logger, codemodsTarget } = options; - // Make sure we're resolving the correct working directory based on the given input + // Resolves the correct working directory based on the given input const cwd = path.resolve(options.cwd ?? process.cwd()); const project = projectFactory(cwd); @@ -49,17 +51,11 @@ export const upgrade = async (options: UpgradeOptions) => { upgrader.overrideCodemodsTarget(codemodsTarget); } - // Don't add the same requirements when manually targeting a major upgrade - // using a semver as it's implied that the users know what they're doing - if (options.target === Version.ReleaseType.Major) { - upgrader - .addRequirement(requirements.major.REQUIRE_AVAILABLE_NEXT_MAJOR) - .addRequirement(requirements.major.REQUIRE_LATEST_FOR_CURRENT_MAJOR); - } + // Prompt user for confirmation details before upgrading + await runUpgradePrompts(upgrader, options); - // Make sure the git repository is in an optimal state before running the upgrade - // Mainly used to ease rollbacks in case the upgrade is corrupted - upgrader.addRequirement(requirements.common.REQUIRE_GIT.asOptional()); + // Add specific requirements before upgrading + addUpgradeRequirements(upgrader, options); // Actually run the upgrade process once configured, // The response contains information about the final status: success/error @@ -73,3 +69,23 @@ export const upgrade = async (options: UpgradeOptions) => { logger.info(`Completed in ${f.durationMs(timer.elapsedMs)}ms`); }; + +const runUpgradePrompts = async (upgrader: Upgrader, options: UpgradeOptions) => { + if (options.target === Version.ReleaseType.Latest) { + await prompts.latest(upgrader, options); + } +}; + +const addUpgradeRequirements = (upgrader: Upgrader, options: UpgradeOptions): void => { + // Don't add the same requirements when manually targeting a major upgrade + // using a semver as it's implied that the users know what they're doing + if (options.target === Version.ReleaseType.Major) { + upgrader + .addRequirement(requirements.major.REQUIRE_AVAILABLE_NEXT_MAJOR) + .addRequirement(requirements.major.REQUIRE_LATEST_FOR_CURRENT_MAJOR); + } + + // Make sure the git repository is in an optimal state before running the upgrade + // Mainly used to ease rollbacks in case the upgrade is corrupted + upgrader.addRequirement(requirements.common.REQUIRE_GIT.asOptional()); +};