diff --git a/code/lib/cli/src/detect.ts b/code/lib/cli/src/detect.ts index c1b9317bc55..b62288bc82e 100644 --- a/code/lib/cli/src/detect.ts +++ b/code/lib/cli/src/detect.ts @@ -15,6 +15,7 @@ import { } from './project_types'; import { commandLog, isNxProject } from './helpers'; import type { JsPackageManager, PackageJsonWithMaybeDeps } from './js-package-manager'; +import { HandledError } from './HandledError'; const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; const webpackConfigFiles = ['webpack.config.js']; @@ -135,16 +136,23 @@ export async function detectBuilder(packageManager: JsPackageManager, projectTyp return CoreBuilder.Webpack5; default: // eslint-disable-next-line no-case-declarations - const { builder } = await prompts({ - type: 'select', - name: 'builder', - message: - 'We were not able to detect the right builder for your project. Please select one:', - choices: [ - { title: 'Vite', value: CoreBuilder.Vite }, - { title: 'Webpack 5', value: CoreBuilder.Webpack5 }, - ], - }); + const { builder } = await prompts( + { + type: 'select', + name: 'builder', + message: + '\nWe were not able to detect the right builder for your project. Please select one:', + choices: [ + { title: 'Vite', value: CoreBuilder.Vite }, + { title: 'Webpack 5', value: CoreBuilder.Webpack5 }, + ], + }, + { + onCancel: () => { + throw new HandledError('Canceled by the user'); + }, + } + ); return builder; } diff --git a/code/lib/cli/src/generators/EMBER/index.ts b/code/lib/cli/src/generators/EMBER/index.ts index 255409fd7c4..313dcf8691f 100644 --- a/code/lib/cli/src/generators/EMBER/index.ts +++ b/code/lib/cli/src/generators/EMBER/index.ts @@ -1,16 +1,23 @@ +import { CoreBuilder } from '../../project_types'; import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'ember', { - extraPackages: [ - // babel-plugin-ember-modules-api-polyfill is a peerDep of @storybook/ember - 'babel-plugin-ember-modules-api-polyfill', - // babel-plugin-htmlbars-inline-precompile is a peerDep of @storybook/ember - 'babel-plugin-htmlbars-inline-precompile', - ], - staticDir: 'dist', - }); + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Webpack5 }, + 'ember', + { + extraPackages: [ + // babel-plugin-ember-modules-api-polyfill is a peerDep of @storybook/ember + 'babel-plugin-ember-modules-api-polyfill', + // babel-plugin-htmlbars-inline-precompile is a peerDep of @storybook/ember + 'babel-plugin-htmlbars-inline-precompile', + ], + staticDir: 'dist', + } + ); }; export default generator; diff --git a/code/lib/cli/src/generators/NEXTJS/index.ts b/code/lib/cli/src/generators/NEXTJS/index.ts index ef3afea02d6..2588b387312 100644 --- a/code/lib/cli/src/generators/NEXTJS/index.ts +++ b/code/lib/cli/src/generators/NEXTJS/index.ts @@ -1,3 +1,4 @@ +import { CoreBuilder } from '../../project_types'; import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; @@ -5,7 +6,7 @@ const generator: Generator = async (packageManager, npmOptions, options) => { await baseGenerator( packageManager, npmOptions, - options, + { ...options, builder: CoreBuilder.Webpack5 }, 'react', { extraAddons: ['@storybook/addon-onboarding'], diff --git a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts index 4059beedec0..5a0300c25b5 100644 --- a/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts +++ b/code/lib/cli/src/generators/REACT_SCRIPTS/index.ts @@ -26,25 +26,15 @@ const generator: Generator = async (packageManager, npmOptions, options) => { : {}; const craVersion = await packageManager.getPackageVersion('react-scripts'); - const isCra5OrHigher = craVersion && semver.gte(craVersion, '5.0.0'); - const updatedOptions = isCra5OrHigher ? { ...options, builder: CoreBuilder.Webpack5 } : options; - const extraPackages = []; - if (isCra5OrHigher) { - extraPackages.push('webpack'); - // Miscellaneous dependency used in `babel-preset-react-app` but not listed as dep there - extraPackages.push('babel-plugin-named-exports-order'); - // Miscellaneous dependency to add to be sure Storybook + CRA is working fine with Yarn PnP mode - extraPackages.push('prop-types'); + if (craVersion === null) { + throw new Error(dedent` + It looks like you're trying to initialize Storybook in a CRA project that does not have react-scripts installed. + Please install it and make sure it's of version 5 or higher, which are the versions supported by Storybook 7.0+. + `); } - const version = versions['@storybook/preset-create-react-app']; - const extraAddons = [ - `@storybook/preset-create-react-app@${version}`, - '@storybook/addon-onboarding', - ]; - - if (!isCra5OrHigher) { + if (!craVersion && semver.gte(craVersion, '5.0.0')) { throw new Error(dedent` Storybook 7.0+ doesn't support react-scripts@<5.0.0. @@ -52,13 +42,32 @@ const generator: Generator = async (packageManager, npmOptions, options) => { `); } - await baseGenerator(packageManager, npmOptions, updatedOptions, 'react', { - extraAddons, - extraPackages, - staticDir: fs.existsSync(path.resolve('./public')) ? 'public' : undefined, - skipBabel: true, - extraMain, - }); + const extraPackages = []; + extraPackages.push('webpack'); + // Miscellaneous dependency used in `babel-preset-react-app` but not listed as dep there + extraPackages.push('babel-plugin-named-exports-order'); + // Miscellaneous dependency to add to be sure Storybook + CRA is working fine with Yarn PnP mode + extraPackages.push('prop-types'); + + const version = versions['@storybook/preset-create-react-app']; + const extraAddons = [ + `@storybook/preset-create-react-app@${version}`, + '@storybook/addon-onboarding', + ]; + + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Webpack5 }, + 'react', + { + extraAddons, + extraPackages, + staticDir: fs.existsSync(path.resolve('./public')) ? 'public' : undefined, + skipBabel: true, + extraMain, + } + ); }; export default generator; diff --git a/code/lib/cli/src/generators/SERVER/index.ts b/code/lib/cli/src/generators/SERVER/index.ts index 96032e2c88e..966efee9089 100755 --- a/code/lib/cli/src/generators/SERVER/index.ts +++ b/code/lib/cli/src/generators/SERVER/index.ts @@ -1,10 +1,17 @@ +import { CoreBuilder } from '../../project_types'; import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'server', { - extensions: ['json', 'yaml', 'yml'], - }); + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Vite }, + 'server', + { + extensions: ['json', 'yaml', 'yml'], + } + ); }; export default generator; diff --git a/code/lib/cli/src/generators/SOLID/index.ts b/code/lib/cli/src/generators/SOLID/index.ts index 7dc517f667b..21347d05768 100644 --- a/code/lib/cli/src/generators/SOLID/index.ts +++ b/code/lib/cli/src/generators/SOLID/index.ts @@ -1,8 +1,16 @@ +import { CoreBuilder } from '../../project_types'; import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'solid', {}, 'solid'); + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Vite }, + 'solid', + {}, + 'solid' + ); }; export default generator; diff --git a/code/lib/cli/src/generators/SVELTEKIT/index.ts b/code/lib/cli/src/generators/SVELTEKIT/index.ts index 21565e6a45c..856d1d04c76 100644 --- a/code/lib/cli/src/generators/SVELTEKIT/index.ts +++ b/code/lib/cli/src/generators/SVELTEKIT/index.ts @@ -1,8 +1,16 @@ +import { CoreBuilder } from '../../project_types'; import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'svelte', undefined, 'sveltekit'); + await baseGenerator( + packageManager, + npmOptions, + { ...options, builder: CoreBuilder.Vite }, + 'svelte', + undefined, + 'sveltekit' + ); }; export default generator; diff --git a/code/lib/cli/src/generators/VUE/index.ts b/code/lib/cli/src/generators/VUE/index.ts index 02878a42c11..c1869a53969 100644 --- a/code/lib/cli/src/generators/VUE/index.ts +++ b/code/lib/cli/src/generators/VUE/index.ts @@ -3,9 +3,10 @@ import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - const extraPackages = options.builder === CoreBuilder.Webpack5 ? ['vue-loader@^15.7.0'] : []; await baseGenerator(packageManager, npmOptions, options, 'vue', { - extraPackages, + extraPackages: async ({ builder }) => { + return builder === CoreBuilder.Webpack5 ? ['vue-loader@^15.7.0'] : []; + }, }); }; diff --git a/code/lib/cli/src/generators/VUE3/index.ts b/code/lib/cli/src/generators/VUE3/index.ts index fa08aa327bd..63dbddede7b 100644 --- a/code/lib/cli/src/generators/VUE3/index.ts +++ b/code/lib/cli/src/generators/VUE3/index.ts @@ -3,12 +3,12 @@ import { baseGenerator } from '../baseGenerator'; import type { Generator } from '../types'; const generator: Generator = async (packageManager, npmOptions, options) => { - const extraPackages = - options.builder === CoreBuilder.Webpack5 - ? ['vue-loader@^17.0.0', '@vue/compiler-sfc@^3.2.0'] - : []; await baseGenerator(packageManager, npmOptions, options, 'vue3', { - extraPackages, + extraPackages: async ({ builder }) => { + return builder === CoreBuilder.Webpack5 + ? ['vue-loader@^17.0.0', '@vue/compiler-sfc@^3.2.0'] + : []; + }, }); }; diff --git a/code/lib/cli/src/generators/baseGenerator.ts b/code/lib/cli/src/generators/baseGenerator.ts index 4ac1cd2f8f6..48b2f8affd5 100644 --- a/code/lib/cli/src/generators/baseGenerator.ts +++ b/code/lib/cli/src/generators/baseGenerator.ts @@ -17,6 +17,7 @@ import { extractEslintInfo, suggestESLintPlugin, } from '../automigrate/helpers/eslintPlugin'; +import { detectBuilder } from '../detect'; const logger = console; @@ -175,10 +176,11 @@ export async function baseGenerator( npmOptions: NpmOptions, { language, - builder = CoreBuilder.Webpack5, + builder, pnp, frameworkPreviewParts, yes: skipPrompts, + projectType, }: GeneratorOptions, renderer: SupportedRenderers, options: FrameworkOptions = defaultOptions, @@ -187,6 +189,11 @@ export async function baseGenerator( const isStorybookInMonorepository = packageManager.isStorybookInMonorepo(); const shouldApplyRequireWrapperOnPackageNames = isStorybookInMonorepository || pnp; + if (!builder) { + // eslint-disable-next-line no-param-reassign + builder = await detectBuilder(packageManager, projectType); + } + const { extraAddons: extraAddonPackages, extraPackages, @@ -219,19 +226,28 @@ export async function baseGenerator( shouldApplyRequireWrapperOnPackageNames ); + const extraAddonsToInstall = + typeof extraAddonPackages === 'function' + ? await extraAddonPackages({ + builder: builder || builderInclude, + framework: framework || frameworkInclude, + }) + : extraAddonPackages; + // added to main.js const addons = [ '@storybook/addon-links', '@storybook/addon-essentials', - ...stripVersions(extraAddonPackages), - ]; + ...stripVersions(extraAddonsToInstall), + ].filter(Boolean); + // added to package.json const addonPackages = [ '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/blocks', - ...extraAddonPackages, - ]; + ...extraAddonsToInstall, + ].filter(Boolean); if (hasInteractiveStories(rendererId)) { addons.push('@storybook/addon-interactions'); @@ -265,12 +281,20 @@ export async function baseGenerator( ); } + const extraPackagesToInstall = + typeof extraPackages === 'function' + ? await extraPackages({ + builder: builder || builderInclude, + framework: framework || frameworkInclude, + }) + : extraPackages; + const allPackages = [ 'storybook', getExternalFramework(rendererId) ? undefined : `@storybook/${rendererId}`, ...frameworkPackages, ...addonPackages, - ...extraPackages, + ...extraPackagesToInstall, ].filter(Boolean); const packages = [...new Set(allPackages)].filter( diff --git a/code/lib/cli/src/generators/types.ts b/code/lib/cli/src/generators/types.ts index c4298cc8552..1711505e0bd 100644 --- a/code/lib/cli/src/generators/types.ts +++ b/code/lib/cli/src/generators/types.ts @@ -8,14 +8,17 @@ export type GeneratorOptions = { builder: Builder; linkable: boolean; pnp: boolean; + projectType: ProjectType; frameworkPreviewParts?: FrameworkPreviewParts; // skip prompting the user yes: boolean; }; export interface FrameworkOptions { - extraPackages?: string[]; - extraAddons?: string[]; + extraPackages?: + | string[] + | ((details: { framework: string; builder: string }) => Promise); + extraAddons?: string[] | ((details: { framework: string; builder: string }) => Promise); staticDir?: string; addScripts?: boolean; addMainFile?: boolean; diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index 0b2a75a4a48..39e71ef240f 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-param-reassign */ import type { PackageJson } from 'read-pkg-up'; import chalk from 'chalk'; import prompts from 'prompts'; @@ -6,14 +7,9 @@ import { withTelemetry } from '@storybook/core-server'; import dedent from 'ts-dedent'; import boxen from 'boxen'; +import { readdirSync } from 'fs-extra'; import { installableProjectTypes, ProjectType } from './project_types'; -import { - detect, - isStorybookInstantiated, - detectLanguage, - detectBuilder, - detectPnp, -} from './detect'; +import { detect, isStorybookInstantiated, detectLanguage, detectPnp } from './detect'; import { commandLog, codeLog, paddedLog } from './helpers'; import angularGenerator from './generators/ANGULAR'; import emberGenerator from './generators/EMBER'; @@ -33,10 +29,10 @@ import qwikGenerator from './generators/QWIK'; import svelteKitGenerator from './generators/SVELTEKIT'; import solidGenerator from './generators/SOLID'; import serverGenerator from './generators/SERVER'; -import type { JsPackageManager } from './js-package-manager'; +import type { JsPackageManager, PackageManagerName } from './js-package-manager'; import { JsPackageManagerFactory, useNpmWarning } from './js-package-manager'; import type { NpmOptions } from './NpmOptions'; -import type { CommandOptions } from './generators/types'; +import type { CommandOptions, GeneratorOptions } from './generators/types'; import { HandledError } from './HandledError'; const logger = console; @@ -54,12 +50,13 @@ const installStorybook = async ( const language = await detectLanguage(packageManager); const pnp = await detectPnp(); - const generatorOptions = { + const generatorOptions: GeneratorOptions = { language, - builder: options.builder || (await detectBuilder(packageManager, projectType)), + builder: options.builder, linkable: !!options.linkable, pnp: pnp || options.usePnp, yes: options.yes, + projectType: options.type, }; const runGenerator: () => Promise = async () => { @@ -239,6 +236,45 @@ const projectTypeInquirer = async ( process.exit(0); }; +const getEmptyDirMessage = (packageManagerType: PackageManagerName) => { + const generatorCommandsMap = { + vite: { + npm: 'npm create vite@latest', + yarn1: 'yarn create vite', + yarn2: 'yarn create vite', + pnpm: 'pnpm create vite', + }, + angular: { + npm: 'npx -p @angular/cli ng new my-project --package-manager=npm', + yarn1: 'npx -p @angular/cli ng new my-project --package-manager=yarn', + yarn2: 'npx -p @angular/cli ng new my-project --package-manager=yarn', + pnpm: 'npx -p @angular/cli ng new my-project --package-manager=pnpm', + }, + }; + + return dedent` + Storybook cannot be installed into an empty project. We recommend creating a new project with the following: + + 📦 Vite CLI for React/Vue/Web Components => ${chalk.green( + generatorCommandsMap.vite[packageManagerType] + )} + See ${chalk.yellowBright('https://vitejs.dev/guide/#scaffolding-your-first-vite-project')} + + 📦 Angular CLI => ${chalk.green(generatorCommandsMap.angular[packageManagerType])} + See ${chalk.yellowBright('https://angular.io/cli/new')} + + 📦 Any other tooling of your choice + + Once you've created a project, please re-run ${chalk.green( + 'npx storybook@latest init' + )} inside the project root. For more information, see ${chalk.yellowBright( + 'https://storybook.js.org/docs' + )} + + Good luck! 🚀 + `; +}; + async function doInitiate( options: CommandOptions, pkg: PackageJson @@ -257,6 +293,11 @@ async function doInitiate( pkgMgr = 'npm'; } + + const cwdFolderEntries = readdirSync(process.cwd()); + const isEmptyDir = + cwdFolderEntries.length === 0 || cwdFolderEntries.every((entry) => entry.startsWith('.')); + const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr }); const welcomeMessage = 'storybook init - the simplest way to add a Storybook to your project.'; logger.log(chalk.inverse(`\n ${welcomeMessage} \n`)); @@ -268,6 +309,17 @@ async function doInitiate( updateCheckInterval: 1000 * 60 * 60, // every hour (we could increase this later on.) }); + if (options.force !== true && isEmptyDir) { + logger.log( + boxen(getEmptyDirMessage(packageManager.type), { + borderStyle: 'round', + padding: 1, + borderColor: '#F1618C', + }) + ); + throw new HandledError('Project was initialized in an empty directory.'); + } + let projectType: ProjectType; const projectTypeProvided = options.type; const infoText = projectTypeProvided @@ -310,7 +362,6 @@ async function doInitiate( logger.log(); if (force) { - // eslint-disable-next-line no-param-reassign options.force = true; } else { process.exit(0); diff --git a/code/lib/cli/src/project_types.ts b/code/lib/cli/src/project_types.ts index 17bc9c27a25..25c058dee81 100644 --- a/code/lib/cli/src/project_types.ts +++ b/code/lib/cli/src/project_types.ts @@ -275,7 +275,11 @@ export const unsupportedTemplate: TemplateConfiguration = { }, }; -const notInstallableProjectTypes: ProjectType[] = [ProjectType.UNDETECTED, ProjectType.UNSUPPORTED]; +const notInstallableProjectTypes: ProjectType[] = [ + ProjectType.UNDETECTED, + ProjectType.UNSUPPORTED, + ProjectType.NX, +]; export const installableProjectTypes = Object.values(ProjectType) .filter((type) => !notInstallableProjectTypes.includes(type))