diff --git a/code/lib/cli/src/automigrate/fixes/index.ts b/code/lib/cli/src/automigrate/fixes/index.ts index 17b3d4942be1..022074fa8301 100644 --- a/code/lib/cli/src/automigrate/fixes/index.ts +++ b/code/lib/cli/src/automigrate/fixes/index.ts @@ -26,6 +26,7 @@ import { removeJestTestingLibrary } from './remove-jest-testing-library'; import { addonsAPI } from './addons-api'; import { mdx1to3 } from './mdx-1-to-3'; import { addonPostCSS } from './addon-postcss'; +import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies'; export * from '../types'; @@ -56,6 +57,7 @@ export const allFixes: Fix[] = [ removeLegacyMDX1, webpack5CompilerSetup, mdx1to3, + upgradeStorybookRelatedDependencies, ]; export const initFixes: Fix[] = [eslintPlugin]; diff --git a/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.test.ts b/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.test.ts new file mode 100644 index 000000000000..0c16309647bd --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.test.ts @@ -0,0 +1,95 @@ +import { describe, afterEach, it, expect, vi } from 'vitest'; +import type { StorybookConfig } from '@storybook/types'; +import type { JsPackageManager } from '@storybook/core-common'; +import * as docsUtils from '../../doctor/getIncompatibleStorybookPackages'; + +import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies'; + +vi.mock('../../doctor/getIncompatibleStorybookPackages'); + +const check = async ({ + packageManager, + main: mainConfig = {}, + storybookVersion = '8.0.0', +}: { + packageManager: Partial; + main?: Partial & Record; + storybookVersion?: string; +}) => { + return upgradeStorybookRelatedDependencies.check({ + packageManager: packageManager as any, + configDir: '', + mainConfig: mainConfig as any, + storybookVersion, + }); +}; + +describe('upgrade-storybook-related-dependencies fix', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should detect storyshots registered in main.js', async () => { + const analyzedPackages = [ + { + packageName: '@chromatic-com/storybook', + packageVersion: '1.2.9', + availableUpgrade: '2.0.0', + hasIncompatibleDependencies: false, + }, + { + packageName: '@storybook/jest', + packageVersion: '0.2.3', + availableUpgrade: '1.0.0', + hasIncompatibleDependencies: false, + }, + { + packageName: '@storybook/preset-create-react-app', + packageVersion: '3.2.0', + availableUpgrade: '8.0.0', + hasIncompatibleDependencies: true, + }, + { + packageName: 'storybook', + packageVersion: '8.0.0', + availableUpgrade: undefined, + hasIncompatibleDependencies: true, + }, + ]; + vi.mocked(docsUtils.getIncompatibleStorybookPackages).mockResolvedValue(analyzedPackages); + await expect( + check({ + packageManager: { + getAllDependencies: async () => ({ + '@chromatic-com/storybook': '1.2.9', + '@storybook/jest': '0.2.3', + '@storybook/preset-create-react-app': '3.2.0', + storybook: '8.0.0', + }), + latestVersion: async (pkgName) => + analyzedPackages.find((pkg) => pkg.packageName === pkgName)?.availableUpgrade || '', + }, + }) + ).resolves.toMatchInlineSnapshot(` + { + "upgradable": [ + { + "afterVersion": "2.0.0", + "beforeVersion": "1.2.9", + "packageName": "@chromatic-com/storybook", + }, + { + "afterVersion": "1.0.0", + "beforeVersion": "0.2.3", + "packageName": "@storybook/jest", + }, + { + "afterVersion": "8.0.0", + "beforeVersion": "3.2.0", + "packageName": "@storybook/preset-create-react-app", + }, + ], + } + `); + }); +}); diff --git a/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.ts b/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.ts new file mode 100644 index 000000000000..5614b7e35ad0 --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/upgrade-storybook-related-dependencies.ts @@ -0,0 +1,162 @@ +import { dedent } from 'ts-dedent'; +import { cyan, yellow } from 'chalk'; +import { valid, coerce } from 'semver'; +import type { JsPackageManager } from '@storybook/core-common'; +import { isCorePackage } from '@storybook/core-common'; +import type { Fix } from '../types'; +import { getIncompatibleStorybookPackages } from '../../doctor/getIncompatibleStorybookPackages'; + +type PackageMetadata = { + packageName: string; + beforeVersion: string | null; + afterVersion: string | null; +}; + +interface Options { + upgradable: PackageMetadata[]; +} + +async function getLatestVersions( + packageManager: JsPackageManager, + packages: [string, string][] +): Promise { + return Promise.all( + packages.map(async ([packageName, beforeVersion]) => ({ + packageName, + beforeVersion: coerce(beforeVersion)?.toString() || null, + afterVersion: await packageManager.latestVersion(packageName).catch(() => null), + })) + ); +} + +function isPackageUpgradable( + afterVersion: string, + packageName: string, + allDependencies: Record +) { + const installedVersion = coerce(allDependencies[packageName])?.toString(); + + return valid(afterVersion) && afterVersion !== installedVersion; +} + +/** + * Is the user upgrading to the `latest` version of Storybook? + * Let's try to pull along some of the storybook related dependencies to `latest` as well! + * + * We communicate clearly that this migration is a helping hand, but not a complete solution. + * The user should still manually check for other dependencies that might be incompatible. + * + * see: https://github.com/storybookjs/storybook/issues/25731#issuecomment-1977346398 + */ +export const upgradeStorybookRelatedDependencies = { + id: 'upgradeStorybookRelatedDependencies', + versionRange: ['*.*.*', '*.*.*'], + promptType: 'auto', + promptDefaultValue: false, + + async check({ packageManager, storybookVersion }) { + const analyzedPackages = await getIncompatibleStorybookPackages({ + currentStorybookVersion: storybookVersion, + packageManager, + skipErrors: true, + }); + + const allDependencies = (await packageManager.getAllDependencies()) as Record; + const storybookDependencies = Object.keys(allDependencies) + .filter((dep) => dep.includes('storybook')) + .filter((dep) => !isCorePackage(dep)); + const incompatibleDependencies = analyzedPackages + .filter((pkg) => pkg.hasIncompatibleDependencies) + .map((pkg) => pkg.packageName); + + const uniquePackages = Array.from( + new Set([...storybookDependencies, ...incompatibleDependencies]) + ).map((packageName) => [packageName, allDependencies[packageName]]) as [string, string][]; + + const packageVersions = await getLatestVersions(packageManager, uniquePackages); + + const upgradablePackages = packageVersions.filter( + ({ packageName, afterVersion, beforeVersion }) => { + if (beforeVersion === null || afterVersion === null) { + return false; + } + + return isPackageUpgradable(afterVersion, packageName, allDependencies); + } + ); + + return upgradablePackages.length > 0 ? { upgradable: upgradablePackages } : null; + }, + + prompt({ upgradable }) { + return dedent` + You're upgrading to the latest version of Storybook. We recommend upgrading the following packages: + ${upgradable + .map(({ packageName, afterVersion, beforeVersion }) => { + return `- ${cyan(packageName)}: ${cyan(beforeVersion)} => ${cyan(afterVersion)}`; + }) + .join('\n')} + + After upgrading, we will run the dedupe command, which could possibly have effects on dependencies that are not Storybook related. + see: https://docs.npmjs.com/cli/commands/npm-dedupe + + Do you want to proceed (upgrade the detected packages)? + `; + }, + + async run({ result: { upgradable }, packageManager, dryRun }) { + if (dryRun) { + console.log(dedent` + We would have upgrade the following: + ${upgradable + .map( + ({ packageName, afterVersion, beforeVersion }) => + `${packageName}: ${beforeVersion} => ${afterVersion}` + ) + .join('\n')} + `); + return; + } + + if (upgradable.length > 0) { + const packageJson = await packageManager.readPackageJson(); + + upgradable.forEach((item) => { + if (!item) { + return; + } + + const { packageName, afterVersion: version } = item; + const prefixed = `^${version}`; + + if (packageJson.dependencies?.[packageName]) { + packageJson.dependencies[packageName] = prefixed; + } + if (packageJson.devDependencies?.[packageName]) { + packageJson.devDependencies[packageName] = prefixed; + } + if (packageJson.peerDependencies?.[packageName]) { + packageJson.peerDependencies[packageName] = prefixed; + } + }); + + await packageManager.writePackageJson(packageJson); + await packageManager.installDependencies(); + + await packageManager + .executeCommand({ command: 'dedupe', args: [], stdio: 'ignore' }) + .catch(() => {}); + + console.log(); + console.log(dedent` + We upgraded ${yellow(upgradable.length)} packages: + ${upgradable + .map(({ packageName, afterVersion, beforeVersion }) => { + return `- ${cyan(packageName)}: ${cyan(beforeVersion)} => ${cyan(afterVersion)}`; + }) + .join('\n')} + `); + } + console.log(); + }, +} satisfies Fix; diff --git a/code/lib/cli/src/automigrate/index.ts b/code/lib/cli/src/automigrate/index.ts index 8a84476b5e93..20cbf98a4166 100644 --- a/code/lib/cli/src/automigrate/index.ts +++ b/code/lib/cli/src/automigrate/index.ts @@ -29,6 +29,9 @@ import { getMigrationSummary } from './helpers/getMigrationSummary'; import { getStorybookData } from './helpers/mainConfigFile'; import { doctor } from '../doctor'; +import { upgradeStorybookRelatedDependencies } from './fixes/upgrade-storybook-related-dependencies'; +import dedent from 'ts-dedent'; + const logger = console; const LOG_FILE_NAME = 'migration-storybook.log'; const LOG_FILE_PATH = join(process.cwd(), LOG_FILE_NAME); @@ -56,8 +59,16 @@ const cleanup = () => { }; const logAvailableMigrations = () => { - const availableFixes = allFixes.map((f) => chalk.yellow(f.id)).join(', '); - logger.info(`\nThe following migrations are available: ${availableFixes}`); + const availableFixes = allFixes + .map((f) => chalk.yellow(f.id)) + .map((x) => `- ${x}`) + .join('\n'); + + console.log(); + logger.info(dedent` + The following migrations are available: + ${availableFixes} + `); }; export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { @@ -84,7 +95,7 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { throw new Error('Could not determine main config path'); } - await automigrate({ + const outcome = await automigrate({ ...options, packageManager, storybookVersion, @@ -94,7 +105,9 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { isUpgrade: false, }); - await doctor({ configDir, packageManager: options.packageManager }); + if (outcome) { + await doctor({ configDir, packageManager: options.packageManager }); + } }; export const automigrate = async ({ @@ -121,8 +134,21 @@ export const automigrate = async ({ return null; } - const selectedFixes = inputFixes || allFixes; - const fixes = fixId ? selectedFixes.filter((f) => f.id === fixId) : selectedFixes; + const selectedFixes: Fix[] = + inputFixes || + allFixes.filter((fix) => { + // we only allow this automigration when the user explicitly asks for it, or they are upgrading to the latest version of storybook + if ( + fix.id === upgradeStorybookRelatedDependencies.id && + isUpgrade !== 'latest' && + fixId !== upgradeStorybookRelatedDependencies.id + ) { + return false; + } + + return true; + }); + const fixes: Fix[] = fixId ? selectedFixes.filter((f) => f.id === fixId) : selectedFixes; if (fixId && fixes.length === 0) { logger.info(`📭 No migrations found for ${chalk.magenta(fixId)}.`); @@ -143,7 +169,7 @@ export const automigrate = async ({ mainConfigPath, storybookVersion, beforeVersion, - isUpgrade, + isUpgrade: !!isUpgrade, dryRun, yes, }); @@ -314,7 +340,7 @@ export async function runFixes({ type: 'confirm', name: 'fix', message: `Do you want to run the '${chalk.cyan(f.id)}' migration on your project?`, - initial: true, + initial: f.promptDefaultValue ?? true, }, { onCancel: () => { diff --git a/code/lib/cli/src/automigrate/types.ts b/code/lib/cli/src/automigrate/types.ts index d8cc9f06af3e..8dad3d3d2fa9 100644 --- a/code/lib/cli/src/automigrate/types.ts +++ b/code/lib/cli/src/automigrate/types.ts @@ -37,6 +37,7 @@ type BaseFix = { versionRange: [from: string, to: string]; check: (options: CheckOptions) => Promise; prompt: (result: ResultType) => string; + promptDefaultValue?: boolean; }; type PromptType = @@ -74,7 +75,7 @@ export interface AutofixOptions extends Omit