diff --git a/code/frameworks/nextjs/README.md b/code/frameworks/nextjs/README.md index cb00d48ff9df..ab6a8fc9cad5 100644 --- a/code/frameworks/nextjs/README.md +++ b/code/frameworks/nextjs/README.md @@ -99,7 +99,7 @@ npx storybook@latest init This framework is designed to work with Storybook 7. If you’re not already using v7, upgrade with this command: ```bash -npx storybook@latest upgrade --prerelease +npx storybook@latest upgrade ``` #### Automatic migration diff --git a/code/frameworks/preact-vite/README.md b/code/frameworks/preact-vite/README.md index e418166a3b54..1e7d742e1674 100644 --- a/code/frameworks/preact-vite/README.md +++ b/code/frameworks/preact-vite/README.md @@ -22,7 +22,7 @@ npx storybook@latest init This framework is designed to work with Storybook 7. If you’re not already using v7, upgrade with this command: ```bash -npx storybook@latest upgrade --prerelease +npx storybook@latest upgrade ``` #### Manual migration diff --git a/code/frameworks/sveltekit/README.md b/code/frameworks/sveltekit/README.md index c3c01c771897..0243fde57b23 100644 --- a/code/frameworks/sveltekit/README.md +++ b/code/frameworks/sveltekit/README.md @@ -17,7 +17,6 @@ Check out our [Frameworks API](https://storybook.js.org/blog/framework-api/) ann - [Mocking links](#mocking-links) - [Troubleshooting](#troubleshooting) - [Error: `ERR! SyntaxError: Identifier '__esbuild_register_import_meta_url__' has already been declared` when starting Storybook](#error-err-syntaxerror-identifier-__esbuild_register_import_meta_url__-has-already-been-declared-when-starting-storybook) - - [Error: `Cannot read properties of undefined (reading 'disable_scroll_handling')` in preview](#error-cannot-read-properties-of-undefined-reading-disable_scroll_handling-in-preview) - [Acknowledgements](#acknowledgements) ## Supported features @@ -64,7 +63,7 @@ npx storybook@latest init This framework is designed to work with Storybook 7. If you’re not already using v7, upgrade with this command: ```bash -npx storybook@latest upgrade --prerelease +npx storybook@latest upgrade ``` #### Automatic migration diff --git a/code/lib/cli/src/automigrate/fixes/builder-vite.ts b/code/lib/cli/src/automigrate/fixes/builder-vite.ts index b1d31444e914..6a46f16f15bd 100644 --- a/code/lib/cli/src/automigrate/fixes/builder-vite.ts +++ b/code/lib/cli/src/automigrate/fixes/builder-vite.ts @@ -6,6 +6,7 @@ import { writeConfig } from '@storybook/csf-tools'; import type { Fix } from '../types'; import type { PackageJson } from '../../js-package-manager'; import { updateMainConfig } from '../helpers/mainConfigFile'; +import { getStorybookVersionSpecifier } from '../../helpers'; const logger = console; @@ -68,8 +69,11 @@ export const builderVite: Fix = { logger.info(`✅ Adding '@storybook/builder-vite' as dev dependency`); if (!dryRun) { + const versionToInstall = getStorybookVersionSpecifier( + await packageManager.retrievePackageJson() + ); await packageManager.addDependencies({ installAsDevDependencies: true }, [ - '@storybook/builder-vite', + `@storybook/builder-vite@${versionToInstall}`, ]); } diff --git a/code/lib/cli/src/automigrate/helpers/checkWebpack5Builder.ts b/code/lib/cli/src/automigrate/helpers/checkWebpack5Builder.ts index 860b68fb0718..a350b23fd82b 100644 --- a/code/lib/cli/src/automigrate/helpers/checkWebpack5Builder.ts +++ b/code/lib/cli/src/automigrate/helpers/checkWebpack5Builder.ts @@ -20,9 +20,11 @@ export const checkWebpack5Builder = async ({ To upgrade to the latest stable release, run this from your project directory: - ${chalk.cyan('npx storybook upgrade')} + ${chalk.cyan('npx storybook@latest upgrade')} - Add the ${chalk.cyan('--prerelease')} flag to get the latest prerelease. + To upgrade to the latest pre-release, run this from your project directory: + + ${chalk.cyan('npx storybook@next upgrade')} `.trim() ); return null; diff --git a/code/lib/cli/src/generate.ts b/code/lib/cli/src/generate.ts index fa9acb1f7f09..2170ff79eeb9 100644 --- a/code/lib/cli/src/generate.ts +++ b/code/lib/cli/src/generate.ts @@ -76,15 +76,13 @@ command('remove ') .action((addonName: string, options: any) => remove(addonName, options)); command('upgrade') - .description('Upgrade your Storybook packages to the latest') + .description(`Upgrade your Storybook packages to v${versions.storybook}`) .option( '--package-manager ', 'Force package manager for installing dependencies' ) .option('-y --yes', 'Skip prompting the user') .option('-n --dry-run', 'Only check for upgrades, do not install') - .option('-t --tag ', 'Upgrade to a certain npm dist-tag (e.g. next, prerelease)') - .option('-p --prerelease', 'Upgrade to the pre-release packages') .option('-s --skip-check', 'Skip postinstall version and automigration checks') .option('-c, --config-dir ', 'Directory where to load Storybook configurations from') .action(async (options: UpgradeOptions) => upgrade(options).catch(() => process.exit(1))); diff --git a/code/lib/cli/src/js-package-manager/JsPackageManager.ts b/code/lib/cli/src/js-package-manager/JsPackageManager.ts index 17ec76ad34a3..9555d08a90b2 100644 --- a/code/lib/cli/src/js-package-manager/JsPackageManager.ts +++ b/code/lib/cli/src/js-package-manager/JsPackageManager.ts @@ -134,6 +134,8 @@ export abstract class JsPackageManager { done = commandLog('Installing dependencies'); + logger.log(); + try { await this.runInstall(); done(); diff --git a/code/lib/cli/src/upgrade.test.ts b/code/lib/cli/src/upgrade.test.ts index 6fcea5f84596..69b85cbc1d2a 100644 --- a/code/lib/cli/src/upgrade.test.ts +++ b/code/lib/cli/src/upgrade.test.ts @@ -1,5 +1,22 @@ -import { describe, it, expect } from 'vitest'; -import { addExtraFlags, addNxPackagesToReject, getStorybookVersion } from './upgrade'; +import { describe, it, expect, vi } from 'vitest'; +import { getStorybookCoreVersion } from '@storybook/telemetry'; +import { + UpgradeStorybookToLowerVersionError, + UpgradeStorybookToSameVersionError, +} from '@storybook/core-events/server-errors'; +import { doUpgrade, getStorybookVersion } from './upgrade'; +import type versions from './versions'; + +vi.mock('@storybook/telemetry'); +vi.mock('./versions', async (importOriginal) => { + const originalVersions = ((await importOriginal()) as { default: typeof versions }).default; + return { + default: Object.keys(originalVersions).reduce((acc, key) => { + acc[key] = '8.0.0'; + return acc; + }, {} as Record), + }; +}); describe.each([ ['│ │ │ ├── @babel/code-frame@7.10.3 deduped', null], @@ -22,68 +39,15 @@ describe.each([ }); }); -describe('extra flags', () => { - const extraFlags = { - 'react-scripts@<5': ['--foo'], - }; - const devDependencies = {}; - it('package matches constraints', () => { - expect( - addExtraFlags(extraFlags, [], { dependencies: { 'react-scripts': '4' }, devDependencies }) - ).toEqual(['--foo']); - }); - it('package prerelease matches constraints', () => { - expect( - addExtraFlags(extraFlags, [], { - dependencies: { 'react-scripts': '4.0.0-alpha.0' }, - devDependencies, - }) - ).toEqual(['--foo']); - }); - it('package not matches constraints', () => { - expect( - addExtraFlags(extraFlags, [], { - dependencies: { 'react-scripts': '5.0.0-alpha.0' }, - devDependencies, - }) - ).toEqual([]); - }); - it('no package not matches constraints', () => { - expect( - addExtraFlags(extraFlags, [], { - dependencies: {}, - devDependencies, - }) - ).toEqual([]); - }); -}); +describe('Upgrade errors', () => { + it('should throw an error when upgrading to a lower version number', async () => { + vi.mocked(getStorybookCoreVersion).mockResolvedValue('8.1.0'); -describe('addNxPackagesToReject', () => { - it('reject exists and is in regex pattern', () => { - const flags = ['--reject', '/preset-create-react-app/', '--some-flag', 'hello']; - expect(addNxPackagesToReject(flags)).toMatchObject([ - '--reject', - '"/(preset-create-react-app|@nrwl/storybook|@nx/storybook)/"', - '--some-flag', - 'hello', - ]); + await expect(doUpgrade({} as any)).rejects.toThrowError(UpgradeStorybookToLowerVersionError); }); - it('reject exists and is in unknown pattern', () => { - const flags = ['--some-flag', 'hello', '--reject', '@storybook/preset-create-react-app']; - expect(addNxPackagesToReject(flags)).toMatchObject([ - '--some-flag', - 'hello', - '--reject', - '@storybook/preset-create-react-app,@nrwl/storybook,@nx/storybook', - ]); - }); - it('reject does not exist', () => { - const flags = ['--some-flag', 'hello']; - expect(addNxPackagesToReject(flags)).toMatchObject([ - '--some-flag', - 'hello', - '--reject', - '@nrwl/storybook,@nx/storybook', - ]); + it('should throw an error when upgrading to the same version number', async () => { + vi.mocked(getStorybookCoreVersion).mockResolvedValue('8.0.0'); + + await expect(doUpgrade({} as any)).rejects.toThrowError(UpgradeStorybookToSameVersionError); }); }); diff --git a/code/lib/cli/src/upgrade.ts b/code/lib/cli/src/upgrade.ts index f42cfd13f5cc..8738f1bfea6a 100644 --- a/code/lib/cli/src/upgrade.ts +++ b/code/lib/cli/src/upgrade.ts @@ -1,18 +1,22 @@ import { sync as spawnSync } from 'cross-spawn'; import { telemetry, getStorybookCoreVersion } from '@storybook/telemetry'; -import semver from 'semver'; +import semver, { eq, lt, prerelease } from 'semver'; import { logger } from '@storybook/node-logger'; import { withTelemetry } from '@storybook/core-server'; import { - ConflictingVersionTagsError, - UpgradeStorybookPackagesError, + UpgradeStorybookToLowerVersionError, + UpgradeStorybookToSameVersionError, } from '@storybook/core-events/server-errors'; -import type { PackageJsonWithMaybeDeps, PackageManagerName } from './js-package-manager'; -import { getPackageDetails, JsPackageManagerFactory } from './js-package-manager'; +import chalk from 'chalk'; +import dedent from 'ts-dedent'; +import boxen from 'boxen'; +import type { PackageManagerName } from './js-package-manager'; +import { JsPackageManagerFactory } from './js-package-manager'; import { coerceSemver, commandLog } from './helpers'; import { automigrate } from './automigrate'; import { isCorePackage } from './utils'; +import versions from './versions'; type Package = { package: string; @@ -87,57 +91,7 @@ export const checkVersionConsistency = () => { }); }; -type ExtraFlags = Record; -const EXTRA_FLAGS: ExtraFlags = { - 'react-scripts@<5': ['--reject', '/preset-create-react-app/'], -}; - -export const addExtraFlags = ( - extraFlags: ExtraFlags, - flags: string[], - { dependencies, devDependencies }: PackageJsonWithMaybeDeps -) => { - return Object.entries(extraFlags).reduce( - (acc, entry) => { - const [pattern, extra] = entry; - const [pkg, specifier] = getPackageDetails(pattern); - const pkgVersion = dependencies?.[pkg] || devDependencies?.[pkg]; - - if (pkgVersion && specifier && semver.satisfies(coerceSemver(pkgVersion), specifier)) { - return [...acc, ...extra]; - } - - return acc; - }, - [...flags] - ); -}; - -export const addNxPackagesToReject = (flags: string[]) => { - const newFlags = [...flags]; - const index = flags.indexOf('--reject'); - if (index > -1) { - // Try to understand if it's in the format of a regex pattern - if (newFlags[index + 1].endsWith('/') && newFlags[index + 1].startsWith('/')) { - // Remove last and first slash so that I can add the parentheses - newFlags[index + 1] = newFlags[index + 1].substring(1, newFlags[index + 1].length - 1); - newFlags[index + 1] = `"/(${newFlags[index + 1]}|@nrwl/storybook|@nx/storybook)/"`; - } else { - // Adding the two packages as comma-separated values - // If the existing rejects are in regex format, they will be ignored. - // Maybe we need to find a more robust way to treat rejects? - newFlags[index + 1] = `${newFlags[index + 1]},@nrwl/storybook,@nx/storybook`; - } - } else { - newFlags.push('--reject'); - newFlags.push('@nrwl/storybook,@nx/storybook'); - } - return newFlags; -}; - export interface UpgradeOptions { - tag: string; - prerelease: boolean; skipCheck: boolean; packageManager: PackageManagerName; dryRun: boolean; @@ -147,8 +101,6 @@ export interface UpgradeOptions { } export const doUpgrade = async ({ - tag, - prerelease, skipCheck, packageManager: pkgMgr, dryRun, @@ -158,66 +110,88 @@ export const doUpgrade = async ({ }: UpgradeOptions) => { const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr }); + const currentVersion = versions['@storybook/cli']; const beforeVersion = await getStorybookCoreVersion(); - commandLog(`Checking for latest versions of '@storybook/*' packages\n`); - - if (tag && prerelease) { - throw new ConflictingVersionTagsError(); + if (lt(currentVersion, beforeVersion)) { + throw new UpgradeStorybookToLowerVersionError({ beforeVersion, currentVersion }); } - - let target = 'latest'; - if (prerelease) { - // '@next' is storybook's convention for the latest prerelease tag. - // This used to be 'greatest', but that was not reliable and could pick canaries, etc. - // and random releases of other packages with storybook in their name. - target = '@next'; - } else if (tag) { - target = `@${tag}`; + if (eq(currentVersion, beforeVersion)) { + throw new UpgradeStorybookToSameVersionError({ beforeVersion }); } - let flags = []; - if (!dryRun) flags.push('--upgrade'); - flags.push('--target'); - flags.push(target); - flags = addExtraFlags(EXTRA_FLAGS, flags, await packageManager.retrievePackageJson()); - flags = addNxPackagesToReject(flags); - - const command = 'npx'; - const checkArgs = ['npm-check-updates@latest', '/storybook/', ...flags]; - const check = spawnSync(command, checkArgs, { - stdio: 'pipe', - shell: true, - }); + const latestVersion = await packageManager.latestVersion('@storybook/cli'); + const isOutdated = lt(currentVersion, latestVersion); + const isPrerelease = prerelease(currentVersion) !== null; + + const borderColor = isOutdated ? '#FC521F' : '#F1618C'; + + const messages = { + welcome: `Upgrading Storybook from version ${chalk.bold(beforeVersion)} to version ${chalk.bold( + currentVersion + )}..`, + notLatest: chalk.red(dedent` + This version is behind the latest release, which is: ${chalk.bold(latestVersion)}! + You likely ran the upgrade command through npx, which can use a locally cached version, to upgrade to the latest version please run: + ${chalk.bold('npx storybook@latest upgrade')} + + You may want to CTRL+C to stop, and run with the latest version instead. + `), + prelease: chalk.yellow('This is a pre-release version.'), + }; - if (check.stderr && check.stderr.toString().includes('npm ERR')) { - throw new UpgradeStorybookPackagesError({ - command, - args: checkArgs, - errorMessage: check.stderr.toString(), - }); - } + logger.plain( + boxen( + [messages.welcome] + .concat(isOutdated && !isPrerelease ? [messages.notLatest] : []) + .concat(isPrerelease ? [messages.prelease] : []) + .join('\n'), + { borderStyle: 'round', padding: 1, borderColor } + ) + ); - logger.info(check.stdout.toString()); + const packageJson = await packageManager.retrievePackageJson(); + + const toUpgradedDependencies = (deps: Record) => { + const monorepoDependencies = Object.keys(deps || {}).filter((dependency) => { + // don't upgrade @storybook/preset-create-react-app if react-scripts is < v5 + if (dependency === '@storybook/preset-create-react-app') { + const reactScriptsVersion = + packageJson.dependencies['react-scripts'] ?? packageJson.devDependencies['react-scripts']; + if (reactScriptsVersion && lt(coerceSemver(reactScriptsVersion), '5.0.0')) { + return false; + } + } - const checkSbArgs = ['npm-check-updates@latest', 'sb', ...flags]; - const checkSb = spawnSync(command, checkSbArgs, { - stdio: 'pipe', - shell: true, - }); - logger.info(checkSb.stdout.toString()); - logger.info(checkSb.stderr.toString()); - - if (checkSb.stderr && checkSb.stderr.toString().includes('npm ERR')) { - throw new UpgradeStorybookPackagesError({ - command, - args: checkSbArgs, - errorMessage: checkSb.stderr.toString(), - }); - } + // only upgrade packages that are in the monorepo + return dependency in versions; + }) as Array; + return monorepoDependencies.map( + (dependency) => + // add ^ modifier to the version if this is the latest and stable version + // example output: @storybook/react@^8.0.0 + `${dependency}@${!isOutdated || isPrerelease ? '^' : ''}${versions[dependency]}` + ); + }; + + const upgradedDependencies = toUpgradedDependencies(packageJson.dependencies); + const upgradedDevDependencies = toUpgradedDependencies(packageJson.devDependencies); if (!dryRun) { - commandLog(`Installing upgrades`); + commandLog(`Updating dependencies in ${chalk.cyan('package.json')}..`); + logger.plain(''); + if (upgradedDependencies.length > 0) { + await packageManager.addDependencies( + { installAsDevDependencies: false, skipInstall: true, packageJson }, + upgradedDependencies + ); + } + if (upgradedDevDependencies.length > 0) { + await packageManager.addDependencies( + { installAsDevDependencies: true, skipInstall: true, packageJson }, + upgradedDevDependencies + ); + } await packageManager.installDependencies(); } @@ -234,8 +208,6 @@ export const doUpgrade = async ({ automigrationPreCheckFailure: preCheckFailure || null, }; telemetry('upgrade', { - prerelease, - tag, beforeVersion, afterVersion, ...automigrationTelemetry, diff --git a/code/lib/core-events/src/errors/server-errors.ts b/code/lib/core-events/src/errors/server-errors.ts index 26449debf1a7..34edcb8b41a4 100644 --- a/code/lib/core-events/src/errors/server-errors.ts +++ b/code/lib/core-events/src/errors/server-errors.ts @@ -417,34 +417,55 @@ export class GenerateNewProjectOnInitError extends StorybookError { } } -export class ConflictingVersionTagsError extends StorybookError { +export class UpgradeStorybookToLowerVersionError extends StorybookError { readonly category = Category.CLI_UPGRADE; - readonly code = 1; + readonly code = 3; + + constructor(public data: { beforeVersion: string; currentVersion: string }) { + super(); + } template() { - return 'Cannot set both --tag and --prerelease. Use --tag=next to get the latest prerelease.'; + return dedent` + You are trying to upgrade Storybook to a lower version than the version currently installed. This is not supported. + + Storybook version ${this.data.beforeVersion} was detected in your project, but you are trying to "upgrade" to version ${this.data.currentVersion}. + + This usually happens when running the upgrade command without a version specifier, e.g. "npx storybook upgrade". + This will cause npm to run the globally cached storybook binary, which might be an older version. + + Instead you should always run the Storybook CLI with a version specifier to force npm to download the latest version: + + "npx storybook@latest upgrade" + `; } } -export class UpgradeStorybookPackagesError extends StorybookError { +export class UpgradeStorybookToSameVersionError extends StorybookError { readonly category = Category.CLI_UPGRADE; - readonly code = 2; + readonly code = 4; - constructor(public data: { command: string; args: string[]; errorMessage: string }) { + constructor(public data: { beforeVersion: string }) { super(); } template() { return dedent` - There was an error while trying to upgrade your Storybook dependencies. + You are trying to upgrade Storybook to the same version that is currently installed in the project, version ${this.data.beforeVersion}. This is not supported. + + This usually happens when running the upgrade command without a version specifier, e.g. "npx storybook upgrade". + This will cause npm to run the globally cached storybook binary, which might be the same version that you already have. + This also happens if you're running the Storybook CLI that is locally installed in your project. + + If you intended to upgrade to the latest version, you should always run the Storybook CLI with a version specifier to force npm to download the latest version: + + "npx storybook@latest upgrade" - Command: - ${this.data.command} ${this.data.args.join(' ')} + If you intended to re-run automigrations, you should run the "automigrate" command directly instead: - Error: - ${this.data.errorMessage} + "npx storybook@${this.data.beforeVersion} automigrate" `; } } diff --git a/code/lib/core-server/src/utils/update-check.ts b/code/lib/core-server/src/utils/update-check.ts index aa57eb08399d..bb387e6e75f2 100644 --- a/code/lib/core-server/src/utils/update-check.ts +++ b/code/lib/core-server/src/utils/update-check.ts @@ -38,8 +38,7 @@ export function createUpdateMessage(updateInfo: VersionCheck, version: string): try { const isPrerelease = semver.prerelease(updateInfo.data.latest.version); - const suffix = isPrerelease ? '@next upgrade --prerelease' : '@latest upgrade'; - const upgradeCommand = `npx storybook${suffix}`; + const upgradeCommand = `npx storybook@${isPrerelease ? 'next' : 'latest'} upgrade`; updateMessage = updateInfo.success && semver.lt(version, updateInfo.data.latest.version) ? dedent`