diff --git a/.circleci/config.yml b/.circleci/config.yml index e3f8ef172a8d..070254689e97 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -472,6 +472,98 @@ jobs: command: yarn upload-bench $(yarn get-template --cadence << pipeline.parameters.workflow >> --task bench) - report-workflow-on-failure: template: $(yarn get-template --cadence << pipeline.parameters.workflow >> --task bench) + test-empty-init: + executor: + class: medium + name: sb_node_16_browsers + parameters: + packageManager: + type: string + template: + type: string + steps: + - git-shallow-clone/checkout_advanced: + clone_options: '--depth 1 --verbose' + - attach_workspace: + at: . + - when: + condition: + equal: ["npm", << parameters.packageManager >>] + steps: + - run: + name: Storybook init from empty directory (NPM) + command: | + cd code + yarn local-registry --open & + cd ../../ + mkdir empty-<< parameters.template >> + cd empty-<< parameters.template >> + npm set registry http://localhost:6001 + npx storybook init --yes --package-manager npm + npm run storybook -- --smoke-test + environment: + IN_STORYBOOK_SANDBOX: true + STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >> + STORYBOOK_DISABLE_TELEMETRY: true + - when: + condition: + equal: ["yarn1", << parameters.packageManager >>] + steps: + - run: + name: Storybook init from empty directory (Yarn 1) + command: | + cd code + yarn local-registry --open & + cd ../../ + mkdir empty-<< parameters.template >> + cd empty-<< parameters.template >> + npx storybook init --yes --package-manager yarn1 + yarn storybook --smoke-test + environment: + IN_STORYBOOK_SANDBOX: true + STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >> + STORYBOOK_DISABLE_TELEMETRY: true + - when: + condition: + equal: ["yarn2", << parameters.packageManager >>] + steps: + - run: + name: Storybook init from empty directory (Yarn 2) + command: | + cd code + yarn local-registry --open & + cd ../../ + mkdir empty-<< parameters.template >> + cd empty-<< parameters.template >> + yarn set version berry + yarn config set registry http://localhost:6001 + yarn dlx storybook init --yes --package-manager yarn2 + yarn storybook --smoke-test + environment: + IN_STORYBOOK_SANDBOX: true + STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >> + STORYBOOK_DISABLE_TELEMETRY: true + - when: + condition: + equal: ["pnpm", << parameters.packageManager >>] + steps: + - run: + name: Storybook init from empty directory (PNPM) + command: | + cd code + yarn local-registry --open & + cd ../../ + mkdir empty-<< parameters.template >> + cd empty-<< parameters.template >> + npm i -g pnpm + pnpm config set registry http://localhost:6001 + pnpm dlx storybook init --yes --package-manager pnpm + pnpm run storybook --smoke-test + environment: + IN_STORYBOOK_SANDBOX: true + STORYBOOK_INIT_EMPTY_TYPE: << parameters.template >> + STORYBOOK_DISABLE_TELEMETRY: true + - report-workflow-on-failure workflows: docs: @@ -640,6 +732,26 @@ workflows: parallelism: 29 requires: - build-sandboxes + + - test-empty-init: + requires: + - build + matrix: + parameters: + packageManager: + - "npm" + # TODO: reenable once we find out the source of failure + # - "yarn1" + # - "yarn2" + # - "pnpm" + template: + - "react-vite-ts" + - "nextjs-ts" + - "vue-vite-ts" + # --smoke-test is not supported for the angular builder right now + # - "angular-cli" + - "lit-vite-ts" + # TODO: reenable once we find out the source of flakyness # - test-runner-dev: # parallelism: 4 diff --git a/code/lib/cli/package.json b/code/lib/cli/package.json index b7201c92e647..dd845db3b8c1 100644 --- a/code/lib/cli/package.json +++ b/code/lib/cli/package.json @@ -1,11 +1,13 @@ { "name": "@storybook/cli", "version": "8.0.0-alpha.1", - "description": "Storybook's CLI - easiest method of adding storybook to your projects", + "description": "Storybook's CLI - install, dev, build, upgrade, and more", "keywords": [ "cli", "generator", - "storybook" + "dev", + "build", + "upgrade" ], "homepage": "https://github.com/storybookjs/storybook/tree/next/code/lib/cli", "bugs": { diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index c3f04ea89964..5217bca197aa 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -8,7 +8,6 @@ import { NxProjectDetectedError } from '@storybook/core-events/server-errors'; import dedent from 'ts-dedent'; import boxen from 'boxen'; -import { readdirSync } from 'fs-extra'; import type { Builder } from './project_types'; import { installableProjectTypes, ProjectType } from './project_types'; import { detect, isStorybookInstantiated, detectLanguage, detectPnp } from './detect'; @@ -29,11 +28,13 @@ import qwikGenerator from './generators/QWIK'; import svelteKitGenerator from './generators/SVELTEKIT'; import solidGenerator from './generators/SOLID'; import serverGenerator from './generators/SERVER'; -import type { JsPackageManager, PackageManagerName } from './js-package-manager'; +import type { JsPackageManager } from './js-package-manager'; import { JsPackageManagerFactory, useNpmWarning } from './js-package-manager'; import type { NpmOptions } from './NpmOptions'; import type { CommandOptions, GeneratorOptions } from './generators/types'; import { HandledError } from './HandledError'; +import { currentDirectoryIsEmpty, scaffoldNewProject } from './scaffold-new-project'; +import versions from './versions'; const logger = console; @@ -222,45 +223,6 @@ 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 @@ -280,11 +242,10 @@ 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 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`)); @@ -295,15 +256,16 @@ 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.'); + // Check if the current directory is empty. + if (options.force !== true && currentDirectoryIsEmpty(packageManager.type)) { + // Prompt the user to create a new project from our list. + await scaffoldNewProject(packageManager.type, options); + + if (process.env.IN_STORYBOOK_SANDBOX === 'true' || process.env.CI === 'true') { + packageManager.addPackageResolutions({ + '@storybook/telemetry': versions['@storybook/telemetry'], + }); + } } let projectType: ProjectType; @@ -386,6 +348,7 @@ async function doInitiate( projectType === ProjectType.ANGULAR ? `ng run ${installResult.projectName}:storybook` : packageManager.getRunStorybookCommand(); + logger.log( boxen( dedent` diff --git a/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.test.ts b/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.test.ts index 4567987488ef..7e12ac20de6b 100644 --- a/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.test.ts +++ b/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.test.ts @@ -13,20 +13,26 @@ const spawnSyncMock = spawnSync as jest.Mock; jest.mock('find-up'); const findUpSyncMock = findUpSync as unknown as jest.Mock; -describe('JsPackageManagerFactory', () => { +describe('CLASS: JsPackageManagerFactory', () => { beforeEach(() => { findUpSyncMock.mockReturnValue(undefined); + delete process.env.npm_config_user_agent; }); - describe('getPackageManager', () => { - describe('return an NPM proxy', () => { - it('when `force` option is `npm`', () => { + describe('METHOD: getPackageManager', () => { + describe('NPM proxy', () => { + it('FORCE: it should return a NPM proxy when `force` option is `npm`', () => { expect(JsPackageManagerFactory.getPackageManager({ force: 'npm' })).toBeInstanceOf( NPMProxy ); }); - it('when all package managers are ok, but only a `package-lock.json` file', () => { + it('USER AGENT: it should infer npm from the user agent', () => { + process.env.npm_config_user_agent = 'npm/7.24.0'; + expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(NPMProxy); + }); + + it('ALL EXIST: when all package managers are ok, but only a `package-lock.json` file is found', () => { spawnSyncMock.mockImplementation((command) => { // Yarn is ok if (command === 'yarn') { @@ -62,14 +68,19 @@ describe('JsPackageManagerFactory', () => { }); }); - describe('return a PNPM proxy', () => { - it('when `force` option is `pnpm`', () => { + describe('PNPM proxy', () => { + it('FORCE: it should return a PNPM proxy when `force` option is `pnpm`', () => { expect(JsPackageManagerFactory.getPackageManager({ force: 'pnpm' })).toBeInstanceOf( PNPMProxy ); }); - it('when all package managers are ok, but only a `pnpm-lock.yaml` file', () => { + it('USER AGENT: it should infer pnpm from the user agent', () => { + process.env.npm_config_user_agent = 'pnpm/7.4.0'; + expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(PNPMProxy); + }); + + it('ALL EXIST: when all package managers are ok, but only a `pnpm-lock.yaml` file is found', () => { spawnSyncMock.mockImplementation((command) => { // Yarn is ok if (command === 'yarn') { @@ -104,7 +115,7 @@ describe('JsPackageManagerFactory', () => { expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(PNPMProxy); }); - it('when a pnpm-lock.yaml file is closer than a yarn.lock', () => { + it('PNPM LOCK IF CLOSER: when a pnpm-lock.yaml file is closer than a yarn.lock', () => { // Allow find-up to work as normal, we'll set the cwd to our fixture package findUpSyncMock.mockImplementation(jest.requireActual('find-up').sync); @@ -140,13 +151,18 @@ describe('JsPackageManagerFactory', () => { }); }); - describe('return a Yarn 1 proxy', () => { - it('when `force` option is `yarn1`', () => { + describe('Yarn 1 proxy', () => { + it('FORCE: it should return a Yarn1 proxy when `force` option is `yarn1`', () => { expect(JsPackageManagerFactory.getPackageManager({ force: 'yarn1' })).toBeInstanceOf( Yarn1Proxy ); }); + it('USER AGENT: it should infer yarn1 from the user agent', () => { + process.env.npm_config_user_agent = 'yarn/1.22.11'; + expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(Yarn1Proxy); + }); + it('when Yarn command is ok, Yarn version is <2, NPM is ko, PNPM is ko', () => { spawnSyncMock.mockImplementation((command) => { // Yarn is ok @@ -251,14 +267,19 @@ describe('JsPackageManagerFactory', () => { }); }); - describe('return a Yarn 2 proxy', () => { - it('when `force` option is `yarn2`', () => { + describe('Yarn 2 proxy', () => { + it('FORCE: it should return a Yarn2 proxy when `force` option is `yarn2`', () => { expect(JsPackageManagerFactory.getPackageManager({ force: 'yarn2' })).toBeInstanceOf( Yarn2Proxy ); }); - it('when Yarn command is ok, Yarn version is >=2, NPM is ko, PNPM is ko', () => { + it('USER AGENT: it should infer yarn2 from the user agent', () => { + process.env.npm_config_user_agent = 'yarn/2.2.10'; + expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(Yarn2Proxy); + }); + + it('ONLY YARN 2: when Yarn command is ok, Yarn version is >=2, NPM is ko, PNPM is ko', () => { spawnSyncMock.mockImplementation((command) => { // Yarn is ok if (command === 'yarn') { diff --git a/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.ts b/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.ts index 77986fd0c038..b6a6ea297956 100644 --- a/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.ts +++ b/code/lib/cli/src/js-package-manager/JsPackageManagerFactory.ts @@ -14,24 +14,29 @@ const NPM_LOCKFILE = 'package-lock.json'; const PNPM_LOCKFILE = 'pnpm-lock.yaml'; const YARN_LOCKFILE = 'yarn.lock'; +type PackageManagerProxy = + | typeof NPMProxy + | typeof PNPMProxy + | typeof Yarn1Proxy + | typeof Yarn2Proxy; + export class JsPackageManagerFactory { public static getPackageManager( { force }: { force?: PackageManagerName } = {}, cwd?: string ): JsPackageManager { - if (force === 'npm') { - return new NPMProxy({ cwd }); - } - if (force === 'pnpm') { - return new PNPMProxy({ cwd }); - } - if (force === 'yarn1') { - return new Yarn1Proxy({ cwd }); + // Option 1: If the user has provided a forcing flag, we use it + if (force && force in this.PROXY_MAP) { + return new this.PROXY_MAP[force]({ cwd }); } - if (force === 'yarn2') { - return new Yarn2Proxy({ cwd }); + + // Option 2: If the user is running a command via npx/pnpx/yarn create/etc, we infer the package manager from the command + const inferredPackageManager = this.inferPackageManagerFromUserAgent(); + if (inferredPackageManager && inferredPackageManager in this.PROXY_MAP) { + return new this.PROXY_MAP[inferredPackageManager]({ cwd }); } + // Option 3: We try to infer the package manager from the closest lockfile const yarnVersion = getYarnVersion(cwd); const closestLockfilePath = findUpSync([YARN_LOCKFILE, PNPM_LOCKFILE, NPM_LOCKFILE], { @@ -56,6 +61,43 @@ export class JsPackageManagerFactory { throw new Error('Unable to find a usable package manager within NPM, PNPM, Yarn and Yarn 2'); } + + /** + * Look up map of package manager proxies by name + */ + private static PROXY_MAP: Record = { + npm: NPMProxy, + pnpm: PNPMProxy, + yarn1: Yarn1Proxy, + yarn2: Yarn2Proxy, + }; + + /** + * Infer the package manager based on the command the user is running. + * Each package manager sets the `npm_config_user_agent` environment variable with its name and version e.g. "npm/7.24.0" + * Which is really useful when invoking commands via npx/pnpx/yarn create/etc. + */ + private static inferPackageManagerFromUserAgent(): PackageManagerName | undefined { + const userAgent = process.env.npm_config_user_agent; + if (userAgent) { + const packageSpec = userAgent.split(' ')[0]; + const [pkgMgrName, pkgMgrVersion] = packageSpec.split('/'); + + if (pkgMgrName === 'pnpm') { + return 'pnpm'; + } + + if (pkgMgrName === 'npm') { + return 'npm'; + } + + if (pkgMgrName === 'yarn') { + return `yarn${pkgMgrVersion?.startsWith('1.') ? '1' : '2'}`; + } + } + + return undefined; + } } function hasNPM(cwd?: string) { diff --git a/code/lib/cli/src/sandbox-templates.ts b/code/lib/cli/src/sandbox-templates.ts index 90af17b5c191..cea75614ae81 100644 --- a/code/lib/cli/src/sandbox-templates.ts +++ b/code/lib/cli/src/sandbox-templates.ts @@ -31,6 +31,10 @@ export type Template = { * This is used to generate projects which are pushed to https://github.com/storybookjs/sandboxes */ script: string; + /** + * Environment variables to set when running the script. + */ + env?: Record; /** * Used to assert various things about the generated template. * If the template is generated with a different expected framework, it will fail, detecting a possible regression. @@ -586,6 +590,7 @@ export const normal: TemplateKey[] = [ 'bench/react-vite-default-ts-test-build', 'bench/react-webpack-18-ts-test-build', ]; + export const merged: TemplateKey[] = [ ...normal, 'react-webpack/18-ts', @@ -596,6 +601,7 @@ export const merged: TemplateKey[] = [ 'html-webpack/default', 'html-vite/default-ts', ]; + export const daily: TemplateKey[] = [ ...merged, 'angular-cli/prerelease', diff --git a/code/lib/cli/src/scaffold-new-project.ts b/code/lib/cli/src/scaffold-new-project.ts new file mode 100644 index 000000000000..d33d82a32d7a --- /dev/null +++ b/code/lib/cli/src/scaffold-new-project.ts @@ -0,0 +1,239 @@ +import boxen from 'boxen'; +import chalk from 'chalk'; +import execa from 'execa'; +import { readdirSync, remove } from 'fs-extra'; +import prompts from 'prompts'; +import dedent from 'ts-dedent'; + +import { telemetry } from '@storybook/telemetry'; + +import { GenerateNewProjectOnInitError } from '@storybook/core-events/server-errors'; +import { logger } from '@storybook/node-logger'; + +import type { PackageManagerName } from './js-package-manager'; +import type { CommandOptions } from './generators/types'; + +type CoercedPackageManagerName = 'npm' | 'yarn' | 'pnpm'; + +interface SupportedProject { + displayName: { + type: string; + builder?: string; + language: string; + }; + createScript: Record; +} + +/** + * The supported projects. + */ +const SUPPORTED_PROJECTS: Record = { + 'react-vite-ts': { + displayName: { + type: 'React', + builder: 'Vite', + language: 'TS', + }, + createScript: { + npm: 'npm create vite@latest . -- --template react-ts', + yarn: 'yarn create vite@latest . --template react-ts', + pnpm: 'pnpm create vite@latest . --template react-ts', + }, + }, + 'nextjs-ts': { + displayName: { + type: 'Next.js', + language: 'TS', + }, + createScript: { + npm: 'npm create next-app . -- --typescript --use-npm --eslint --tailwind --no-app --import-alias="@/*" --src-dir', + yarn: 'yarn create next-app . --typescript --use-yarn --eslint --tailwind --no-app --import-alias="@/*" --src-dir', + pnpm: 'pnpm create next-app . --typescript --use-pnpm --eslint --tailwind --no-app --import-alias="@/*" --src-dir', + }, + }, + 'vue-vite-ts': { + displayName: { + type: 'Vue 3', + builder: 'Vite', + language: 'TS', + }, + createScript: { + npm: 'npm create vite@latest . -- --template vue-ts', + yarn: 'yarn create vite@latest . --template vue-ts', + pnpm: 'pnpm create vite@latest . --template vue-ts', + }, + }, + 'angular-cli': { + displayName: { + type: 'Angular', + language: 'TS', + }, + createScript: { + npm: 'npx -p @angular/cli@latest ng new angular-latest --directory . --routing=true --minimal=true --style=scss --strict --skip-git --skip-install', + yarn: 'yarn dlx -p @angular/cli ng new angular-latest --directory . --routing=true --minimal=true --style=scss --strict --skip-git --package-manager=yarn --skip-install && touch yarn.lock && yarn set version berry && yarn config set nodeLinker node-modules', + pnpm: 'pnpm --package @angular/cli dlx ng new angular-latest --directory . --routing=true --minimal=true --style=scss --strict --skip-git --package-manager=pnpm --skip-install', + }, + }, + 'lit-vite-ts': { + displayName: { + type: 'Lit', + builder: 'Vite', + language: 'TS', + }, + createScript: { + npm: 'npm create vite@latest . -- --template lit-ts', + yarn: 'yarn create vite@latest . --template lit-ts && touch yarn.lock && yarn set version berry && yarn config set nodeLinker pnp', + pnpm: 'pnpm create vite@latest . --template lit-ts', + }, + }, +}; + +const packageManagerToCoercedName = ( + packageManager: PackageManagerName +): CoercedPackageManagerName => { + switch (packageManager) { + case 'npm': + return 'npm'; + case 'pnpm': + return 'pnpm'; + default: + return 'yarn'; + } +}; + +const buildProjectDisplayNameForPrint = ({ displayName }: SupportedProject) => { + const { type, builder, language } = displayName; + return `${chalk.bold.blue(type)} ${builder ? `+ ${builder} ` : ''}(${language})`; +}; + +/** + * Scaffold a new project. + * + * @param packageManager The package manager to use. + */ +export const scaffoldNewProject = async ( + packageManager: PackageManagerName, + { disableTelemetry }: CommandOptions +) => { + const packageManagerName = packageManagerToCoercedName(packageManager); + + logger.plain( + boxen( + dedent` + Would you like to generate a new project from the following list? + + ${chalk.bold('Note:')} + Storybook supports many more frameworks and bundlers than listed below. If you don't see your + preferred setup, you can still generate a project then rerun this command to add Storybook. + + ${chalk.bold('Press ^C at any time to quit.')} + `, + { + title: chalk.bold('πŸ”Ž Empty directory detected'), + padding: 1, + borderStyle: 'double', + borderColor: 'yellow', + } + ) + ); + logger.line(1); + + let projectStrategy; + + if (process.env.STORYBOOK_INIT_EMPTY_TYPE) { + projectStrategy = process.env.STORYBOOK_INIT_EMPTY_TYPE; + } + + if (!projectStrategy) { + const { project } = await prompts( + { + type: 'select', + name: 'project', + message: 'Choose a project template', + choices: Object.entries(SUPPORTED_PROJECTS).map(([key, value]) => ({ + title: buildProjectDisplayNameForPrint(value), + value: key, + })), + }, + { onCancel: () => process.exit(0) } + ); + + projectStrategy = project; + } + + const projectStrategyConfig = SUPPORTED_PROJECTS[projectStrategy]; + const projectDisplayName = buildProjectDisplayNameForPrint(projectStrategyConfig); + const createScript = projectStrategyConfig.createScript[packageManagerName]; + + logger.line(1); + logger.plain( + `Creating a new "${projectDisplayName}" project with ${chalk.bold(packageManagerName)}...` + ); + logger.line(1); + + const targetDir = process.cwd(); + + try { + // If target directory has a .cache folder, remove it + // so that it does not block the creation of the new project + await remove(`${targetDir}/.cache`); + + // Create new project in temp directory + await execa.command(createScript, { + stdio: 'pipe', + shell: true, + cwd: targetDir, + cleanup: true, + }); + } catch (e) { + throw new GenerateNewProjectOnInitError({ + error: e, + packageManager: packageManagerName, + projectType: projectStrategy, + }); + } + + if (!disableTelemetry) { + telemetry('scaffolded-empty', { + packageManager: packageManagerName, + projectType: projectStrategy, + }); + } + + logger.plain( + boxen( + dedent` + "${projectDisplayName}" project with ${chalk.bold(packageManagerName)} created successfully! + + Continuing with Storybook installation... + `, + { + title: chalk.bold('βœ… Success!'), + padding: 1, + borderStyle: 'double', + borderColor: 'green', + } + ) + ); + logger.line(1); +}; + +const BASE_IGNORED_FILES = ['.git', '.gitignore', '.DS_Store', '.cache']; + +const IGNORED_FILES_BY_PACKAGE_MANAGER: Record = { + npm: [...BASE_IGNORED_FILES], + yarn: [...BASE_IGNORED_FILES, '.yarnrc.yml', '.yarn'], + pnpm: [...BASE_IGNORED_FILES], +}; + +export const currentDirectoryIsEmpty = (packageManager: PackageManagerName) => { + const packageManagerName = packageManagerToCoercedName(packageManager); + const cwdFolderEntries = readdirSync(process.cwd()); + + const filesToIgnore = IGNORED_FILES_BY_PACKAGE_MANAGER[packageManagerName]; + + return ( + cwdFolderEntries.length === 0 || + cwdFolderEntries.every((entry) => filesToIgnore.includes(entry)) + ); +}; diff --git a/code/lib/core-events/src/errors/server-errors.ts b/code/lib/core-events/src/errors/server-errors.ts index f4ecab544773..bacdd7d6055e 100644 --- a/code/lib/core-events/src/errors/server-errors.ts +++ b/code/lib/core-events/src/errors/server-errors.ts @@ -410,3 +410,25 @@ export class NoMatchingExportError extends StorybookError { `; } } + +export class GenerateNewProjectOnInitError extends StorybookError { + readonly category = Category.CLI_INIT; + + readonly code = 3; + + constructor( + public data: { error: unknown | Error; packageManager: string; projectType: string } + ) { + super(); + } + + template(): string { + return dedent` + There was an error while using ${this.data.packageManager} to create a new ${ + this.data.projectType + } project. + + ${this.data.error instanceof Error ? this.data.error.message : ''} + `; + } +} diff --git a/code/lib/telemetry/src/types.ts b/code/lib/telemetry/src/types.ts index 35266814dff7..8f091703bcae 100644 --- a/code/lib/telemetry/src/types.ts +++ b/code/lib/telemetry/src/types.ts @@ -9,6 +9,7 @@ export type EventType = | 'build' | 'upgrade' | 'init' + | 'scaffolded-empty' | 'browser' | 'canceled' | 'error' diff --git a/docs/get-started/install.md b/docs/get-started/install.md index 2d01dfd1a0f2..977a1e122c03 100644 --- a/docs/get-started/install.md +++ b/docs/get-started/install.md @@ -2,7 +2,7 @@ title: 'Install Storybook' --- -Use the Storybook CLI to install it in a single command. Run this inside your _existing project’s_ root directory: +Use the Storybook CLI to install it in a single command. Run this inside your project’s root directory: @@ -16,21 +16,6 @@ Use the Storybook CLI to install it in a single command. Run this inside your _e -
- -storybook init is not made for empty projects - -Storybook needs to be installed into a project that is already set up with a framework. It will not work on an empty project. There are many ways to bootstrap an app in a given framework, including: - -- πŸ“¦ [Create an Angular Workspace](https://angular.io/cli/new) -- πŸ“¦ [Create React App](https://reactjs.org/docs/create-a-new-react-app.html) -- πŸ“¦ [Create a Vue App](https://vuejs.org/guide/quick-start.html) -- πŸ“¦ [Ember CLI](https://guides.emberjs.com/release/getting-started/quick-start/) -- πŸ“¦ [Vite CLI](https://vitejs.dev/guide/#scaffolding-your-first-vite-project) -- Or any other tooling available. - -
- Storybook will look into your project's dependencies during its install process and provide you with the best configuration available. The command above will make the following changes to your local environment: diff --git a/scripts/sandbox/generate.ts b/scripts/sandbox/generate.ts index e14b8bac5f04..0ad2860b536f 100755 --- a/scripts/sandbox/generate.ts +++ b/scripts/sandbox/generate.ts @@ -28,10 +28,15 @@ import { LOCAL_REGISTRY_URL, } from '../utils/constants'; -const sbInit = async (cwd: string, flags?: string[], debug?: boolean) => { +const sbInit = async ( + cwd: string, + envVars: Record = {}, + flags?: string[], + debug?: boolean +) => { const sbCliBinaryPath = join(__dirname, `../../code/lib/cli/bin/index.js`); console.log(`🎁 Installing storybook`); - const env = { STORYBOOK_DISABLE_TELEMETRY: 'true' }; + const env = { STORYBOOK_DISABLE_TELEMETRY: 'true', ...envVars }; const fullFlags = ['--yes', ...(flags || [])]; await runCommand(`${sbCliBinaryPath} init ${fullFlags.join(' ')}`, { cwd, env }, debug); }; @@ -61,11 +66,13 @@ const addStorybook = async ({ localRegistry, flags, debug, + env = {}, }: { baseDir: string; localRegistry: boolean; flags?: string[]; debug?: boolean; + env?: Record; }) => { const beforeDir = join(baseDir, BEFORE_DIR_NAME); const afterDir = join(baseDir, AFTER_DIR_NAME); @@ -84,10 +91,10 @@ const addStorybook = async ({ jackspeak: '2.1.1', }); - await sbInit(tmpDir, flags, debug); + await sbInit(tmpDir, env, flags, debug); }); } else { - await sbInit(tmpDir, flags, debug); + await sbInit(tmpDir, env, flags, debug); } } catch (e) { await remove(tmpDir); @@ -142,7 +149,7 @@ const runGenerators = async ( const limit = pLimit(1); await Promise.all( - generators.map(({ dirName, name, script, expected }) => + generators.map(({ dirName, name, script, expected, env }) => limit(async () => { let flags: string[] = []; if (expected.renderer === '@storybook/html') flags = ['--type html']; @@ -189,7 +196,7 @@ const runGenerators = async ( // Make sure there are no git projects in the folder await remove(join(beforeDir, '.git')); - await addStorybook({ baseDir, localRegistry, flags, debug }); + await addStorybook({ baseDir, localRegistry, flags, debug, env }); await addDocumentation(baseDir, { name, dirName }); diff --git a/scripts/sandbox/utils/types.ts b/scripts/sandbox/utils/types.ts index a9e61af5715d..39d4594aa6e5 100644 --- a/scripts/sandbox/utils/types.ts +++ b/scripts/sandbox/utils/types.ts @@ -6,4 +6,5 @@ export type GeneratorConfig = { renderer: string; builder: string; }; + env?: Record; };