diff --git a/code/lib/cli/src/add.test.ts b/code/lib/cli/src/add.test.ts deleted file mode 100644 index d6632443ea4..00000000000 --- a/code/lib/cli/src/add.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - addStorybookAddonToFile, - storybookAddonScope, - getPackageName, - getInstalledStorybookVersion, - getPackageArg, -} from './add'; - -describe('addStorybookAddonToFile should correctly register an Storybook addon', () => { - test('to an empty array', () => { - expect(addStorybookAddonToFile('addon-name', [], true)).toEqual([ - `import '${storybookAddonScope}addon-name/manager';`, - ]); - }); - - test('to an empty file', () => { - expect(addStorybookAddonToFile('addon-name', [''], true)).toEqual([ - `import '${storybookAddonScope}addon-name/manager';`, - '', - ]); - }); - - test('to an addons file with existing addons registered', () => { - expect( - addStorybookAddonToFile( - 'addon-name', - [ - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - '', - ], - true - ) - ).toEqual([ - `import '${storybookAddonScope}addon-name/manager';`, - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - '', - ]); - }); - - test('to an addons file with more than only imports', () => { - expect( - addStorybookAddonToFile( - 'addon-name', - [ - "import '@storybook/addon-links/manager';", - "import '@storybook/addon-actions/manager';", - '', - '//some other stuff', - '', - 'and more stuff', - '', - ], - true - ) - ).toEqual([ - `import '${storybookAddonScope}addon-name/manager';`, - "import '@storybook/addon-links/manager';", - "import '@storybook/addon-actions/manager';", - '', - '//some other stuff', - '', - 'and more stuff', - '', - ]); - }); - - test('to an addon file with it already being installed by not duplicating it', () => { - expect( - addStorybookAddonToFile( - 'addon-name', - [ - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - `import '${storybookAddonScope}addon-name/manager';`, - '', - ], - true - ) - ).toEqual([ - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - `import '${storybookAddonScope}addon-name/manager';`, - '', - ]); - }); - - test('to an addons file if it is not an official addon', () => { - expect( - addStorybookAddonToFile( - 'addon-name', - [ - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - '', - ], - false - ) - ).toEqual([ - `import 'addon-name/manager';`, - "import '@storybook/addon-actions/manager';", - "import '@storybook/addon-links/manager';", - '', - ]); - }); -}); - -describe('getPackageName should correctly return the full package name', () => { - test('on a normal addon', () => { - const name = 'normal-addon'; - expect(getPackageName(name, false)).toBe(name); - }); - test('on an official addon', () => { - const name = 'official-addon'; - expect(getPackageName(name, true)).toBe(storybookAddonScope + name); - }); -}); - -describe('getInstalledStorybookVersion should return the correct Storybook version', () => { - test('when single official Storybook package is installed', () => { - expect( - getInstalledStorybookVersion({ - devDependencies: { - '@storybook/react': '^4.0.0-alpha.22', - }, - }) - ).toBe('^4.0.0-alpha.22'); - }); - - test('when no official Storybook package is installed', () => { - expect( - getInstalledStorybookVersion({ - devDependencies: { - 'random package': '^4.0.0-alpha.22', - }, - }) - ).toBeFalsy(); - }); - - test('when an unofficial package with "storybook" in its name is installed', () => { - expect( - getInstalledStorybookVersion({ - devDependencies: { - 'not-storybook': '^4.0.0-alpha.22', - }, - }) - ).toBeFalsy(); - }); -}); - -describe('getPackageArg returns the correct package argument to install', () => { - const officialAddonName = 'knob'; - const randomAddonName = 'random'; - const officialAddonNameWithTag = `${officialAddonName}@alpha`; - const randomAddonNameWithTag = `${randomAddonName}@latest`; - - test('when it is an official Storybook addon without any Storybook package installed', () => { - expect( - getPackageArg(officialAddonName, true, { - devDependencies: {}, - }) - ).toBe(officialAddonName); - }); - - test('when it is a random addon without any Storybook package installed', () => { - expect( - getPackageArg(randomAddonName, true, { - devDependencies: {}, - }) - ).toBe(randomAddonName); - }); - - test('when it is a random addon with tag without any Storybook package installed', () => { - expect( - getPackageArg(randomAddonNameWithTag, true, { - devDependencies: {}, - }) - ).toBe(randomAddonNameWithTag); - }); - - test('when it is an official addon with tag without any Storybook package installed', () => { - expect( - getPackageArg(officialAddonNameWithTag, true, { - devDependencies: {}, - }) - ).toBe(officialAddonNameWithTag); - }); - - test('when it is an official addon with tag with a Storybook package installed', () => { - expect( - getPackageArg(officialAddonNameWithTag, true, { - devDependencies: { - '@storybook/html': '^4.0.0-alpha.21', - }, - }) - ).toBe(`${officialAddonName}@^4.0.0-alpha.21`); - }); - - test('when it is an official addon with a Storybook package installed', () => { - expect( - getPackageArg(officialAddonName, true, { - devDependencies: { - '@storybook/html': '^4.0.0-alpha.21', - }, - }) - ).toBe(`${officialAddonName}@^4.0.0-alpha.21`); - }); -}); diff --git a/code/lib/cli/src/add.ts b/code/lib/cli/src/add.ts index f67c50f1e15..369ce570efd 100644 --- a/code/lib/cli/src/add.ts +++ b/code/lib/cli/src/add.ts @@ -1,102 +1,14 @@ import path from 'path'; import fs from 'fs'; import { sync as spawnSync } from 'cross-spawn'; + +import { getStorybookInfo } from '@storybook/core-common'; +import { readConfig, writeConfig } from '@storybook/csf-tools'; + import { commandLog } from './helpers'; -import { JsPackageManager, JsPackageManagerFactory, PackageJson } from './js-package-manager'; +import { JsPackageManagerFactory } from './js-package-manager'; const logger = console; -export const storybookAddonScope = '@storybook/addon-'; - -const isAddon = async (packageManager: JsPackageManager, name: string) => { - try { - await packageManager.latestVersion(name); - return true; - } catch (e) { - return false; - } -}; - -const isStorybookAddon = async (packageManager: JsPackageManager, name: string) => - isAddon(packageManager, `${storybookAddonScope}${name}`); - -export const getPackageName = (addonName: string, isOfficialAddon: boolean) => - isOfficialAddon ? storybookAddonScope + addonName : addonName; - -export const getInstalledStorybookVersion = (packageJson: PackageJson) => - packageJson.devDependencies[ - // This only considers the first occurrence. - Object.keys(packageJson.devDependencies).find((devDep) => /@storybook/.test(devDep)) - ] || false; - -export const getPackageArg = ( - addonName: string, - isOfficialAddon: boolean, - packageJson: PackageJson -) => { - if (isOfficialAddon) { - const addonNameNoTag = addonName.split('@')[0]; - const installedStorybookVersion = getInstalledStorybookVersion(packageJson); - return installedStorybookVersion - ? `${addonNameNoTag}@${getInstalledStorybookVersion(packageJson)}` - : addonName; - } - return addonName; -}; - -const installAddon = ( - packageManager: JsPackageManager, - addonName: string, - isOfficialAddon: boolean -) => { - const prepareDone = commandLog(`Preparing to install the ${addonName} Storybook addon`); - prepareDone(); - logger.log(); - - const packageArg = getPackageArg( - addonName, - isOfficialAddon, - packageManager.retrievePackageJson() - ); - - logger.log(); - const installDone = commandLog(`Installing the ${addonName} Storybook addon`); - - try { - packageManager.addDependencies({}, [packageArg]); - } catch (e) { - installDone( - `Something went wrong installing the addon: "${getPackageName(addonName, isOfficialAddon)}"` - ); - logger.log(); - process.exit(1); - } - installDone(); -}; - -export const addStorybookAddonToFile = ( - addonName: string, - addonsFile: string[], - isOfficialAddon: boolean -) => { - const addonNameNoTag = addonName.split('@')[0]; - const alreadyRegistered = addonsFile.find((line) => line.includes(`${addonNameNoTag}/manager`)); - - if (alreadyRegistered) { - return addonsFile; - } - - const latestImportIndex = addonsFile.reduce( - (prev, curr, currIndex) => - curr.startsWith('import') && curr.includes('register') ? currIndex : prev, - -1 - ); - - return [ - ...addonsFile.slice(0, latestImportIndex + 1), - `import '${getPackageName(addonNameNoTag, isOfficialAddon)}/manager';`, - ...addonsFile.slice(latestImportIndex + 1), - ]; -}; const LEGACY_CONFIGS = ['addons', 'config', 'presets']; @@ -137,23 +49,58 @@ const postinstallAddon = async (addonName: string, isOfficialAddon: boolean) => } }; -export async function add( - addonName: string, - options: { useNpm: boolean; skipPostinstall: boolean } -) { - const packageManager = JsPackageManagerFactory.getPackageManager(options.useNpm); +const getVersionSpecifier = (addon: string) => { + const groups = /^(...*)@(.*)$/.exec(addon); + return groups ? [groups[1], groups[2]] : [addon, undefined]; +}; - const addonCheckDone = commandLog(`Verifying that ${addonName} is an addon`); - const isOfficialAddon = await isStorybookAddon(packageManager, addonName); - if (!isOfficialAddon) { - if (!(await isAddon(packageManager, addonName))) { - addonCheckDone(`The provided package was not a Storybook addon: ${addonName}.`); - return; - } +/** + * Install the given addon package and add it to main.js + * + * Usage: + * - sb add @storybook/addon-docs + * - sb add @storybook/addon-interactions@7.0.1 + * + * If there is no version specifier and it's a storybook addon, + * it will try to use the version specifier matching your current + * Storybook install version. + */ +export async function add(addon: string, options: { useNpm: boolean; skipPostinstall: boolean }) { + const packageManager = JsPackageManagerFactory.getPackageManager(options.useNpm); + const packageJson = packageManager.retrievePackageJson(); + const [addonName, versionSpecifier] = getVersionSpecifier(addon); + + const { mainConfig, version: storybookVersion } = getStorybookInfo(packageJson); + if (!mainConfig) { + logger.error('Unable to find storybook main.js config'); + return; } - addonCheckDone(); - installAddon(packageManager, addonName, isOfficialAddon); + const main = await readConfig(mainConfig); + const addons = main.getFieldValue(['addons']); + if (addons && !Array.isArray(addons)) { + logger.error('Expected addons array in main.js config'); + } + + logger.log(`Verifying ${addonName}`); + const latestVersion = packageManager.latestVersion(addonName); + if (!latestVersion) { + logger.error(`Unknown addon ${addonName}`); + } + + // add to package.json + const isStorybookAddon = addonName.startsWith('@storybook/'); + const version = versionSpecifier || (isStorybookAddon ? storybookVersion : latestVersion); + const addonWithVersion = `${addonName}@${version}`; + logger.log(`Installing ${addonWithVersion}`); + packageManager.addDependencies({ installAsDevDependencies: true }, [addonWithVersion]); + + // add to main.js + logger.log(`Adding '${addon}' to main.js addons field.`); + const updatedAddons = [...(addons || []), addonName]; + main.setFieldValue(['addons'], updatedAddons); + await writeConfig(main); + if (!options.skipPostinstall) { - await postinstallAddon(addonName, isOfficialAddon); + await postinstallAddon(addon, isStorybookAddon); } }