Add <latest> Upgrade Command (#21754)

This commit is contained in:
Jean-Sébastien Herbaux 2024-10-14 13:31:16 +02:00 committed by GitHub
parent f52f276833
commit e8787bcc0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 178 additions and 14 deletions

View File

@ -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,

View File

@ -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()}]`),

View File

@ -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;

View File

@ -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<boolean>;
addRequirement(requirement: Requirement.Requirement): this;
upgrade(): Promise<UpgradeReport>;

View File

@ -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<boolean> {
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<void> {
const codemodRunner = codemodRunnerFactory(this.project, range);
codemodRunner.dry(this.isDry);
if (this.logger) {
codemodRunner.setLogger(this.logger);
}
await codemodRunner.run();
}
}

View File

@ -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(() =>

View File

@ -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', () => {

View File

@ -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".

View File

@ -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',
}

View File

@ -30,7 +30,7 @@ describe('codemods task', () => {
target: Version.ReleaseType.Major,
logger,
dry: false,
selectCodemods: (options) => Promise.resolve(options),
selectCodemods: <T>(options: T) => Promise.resolve(options),
};
(codemodRunnerFactory as jest.Mock).mockReturnValue({

View File

@ -18,6 +18,8 @@ export const getRangeFromTarget = (
const { major, minor, patch } = currentVersion;
switch (target) {
case Version.ReleaseType.Latest:
throw new Error("Can't use <latest> to create a codemods range: not implemented");
case Version.ReleaseType.Major:
return rangeFactory(`${major}`);
case Version.ReleaseType.Minor:

View File

@ -0,0 +1 @@
export * from './latest';

View File

@ -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();
}
}
};

View File

@ -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());
};