mirror of
https://github.com/strapi/strapi.git
synced 2025-12-12 23:44:08 +00:00
Add <latest> Upgrade Command (#21754)
This commit is contained in:
parent
f52f276833
commit
e8787bcc0d
@ -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,
|
||||
|
||||
@ -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()}]`),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(() =>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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".
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from './latest';
|
||||
62
packages/utils/upgrade/src/tasks/upgrade/prompts/latest.ts
Normal file
62
packages/utils/upgrade/src/tasks/upgrade/prompts/latest.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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());
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user