diff --git a/code/lib/cli/src/generate.ts b/code/lib/cli/src/generate.ts index a91fd0517687..fa9acb1f7f09 100644 --- a/code/lib/cli/src/generate.ts +++ b/code/lib/cli/src/generate.ts @@ -11,6 +11,7 @@ import invariant from 'tiny-invariant'; import type { CommandOptions } from './generators/types'; import { initiate } from './initiate'; import { add } from './add'; +import { remove } from './remove'; import { migrate } from './migrate'; import { upgrade, type UpgradeOptions } from './upgrade'; import { sandbox } from './sandbox'; @@ -66,6 +67,14 @@ command('add ') .option('-s --skip-postinstall', 'Skip package specific postinstall config modifications') .action((addonName: string, options: any) => add(addonName, options)); +command('remove ') + .description('Remove an addon from your Storybook') + .option( + '--package-manager ', + 'Force package manager for installing dependencies' + ) + .action((addonName: string, options: any) => remove(addonName, options)); + command('upgrade') .description('Upgrade your Storybook packages to the latest') .option( diff --git a/code/lib/cli/src/remove.ts b/code/lib/cli/src/remove.ts new file mode 100644 index 000000000000..47c556eb578f --- /dev/null +++ b/code/lib/cli/src/remove.ts @@ -0,0 +1,46 @@ +import { getStorybookInfo } from '@storybook/core-common'; +import { readConfig, writeConfig } from '@storybook/csf-tools'; +import dedent from 'ts-dedent'; + +import { JsPackageManagerFactory, type PackageManagerName } from './js-package-manager'; + +const logger = console; + +/** + * Remove the given addon package and remove it from main.js + * + * Usage: + * - sb remove @storybook/addon-links + */ +export async function remove(addon: string, options: { packageManager: PackageManagerName }) { + const { packageManager: pkgMgr } = options; + + const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr }); + const packageJson = await packageManager.retrievePackageJson(); + const { mainConfig, configDir } = getStorybookInfo(packageJson); + + if (typeof configDir === 'undefined') { + throw new Error(dedent` + Unable to find storybook config directory + `); + } + + if (!mainConfig) { + logger.error('Unable to find storybook main.js config'); + return; + } + const main = await readConfig(mainConfig); + + // remove from package.json + logger.log(`Uninstalling ${addon}`); + await packageManager.removeDependencies({ packageJson }, [addon]); + + // add to main.js + logger.log(`Removing '${addon}' from main.js addons field.`); + try { + main.removeEntryFromArray(['addons'], addon); + await writeConfig(main); + } catch (err) { + logger.warn(`Failed to remove '${addon}' from main.js addons field.`); + } +} diff --git a/code/lib/csf-tools/src/ConfigFile.test.ts b/code/lib/csf-tools/src/ConfigFile.test.ts index 2ae59f8a75ac..5fc75ff41f26 100644 --- a/code/lib/csf-tools/src/ConfigFile.test.ts +++ b/code/lib/csf-tools/src/ConfigFile.test.ts @@ -1139,4 +1139,74 @@ describe('ConfigFile', () => { `); }); }); + + describe('removeEntryFromArray', () => { + it('removes a string literal entry', () => { + const source = dedent` + export default { + addons: ['a', 'b', 'c'], + } + `; + const config = loadConfig(source).parse(); + config.removeEntryFromArray(['addons'], 'b'); + expect(config.getFieldValue(['addons'])).toMatchInlineSnapshot(`a,c`); + }); + + it('removes a preset-style object entry', () => { + const source = dedent` + export default { + addons: ['a', { name: 'b', options: {} }, 'c'], + } + `; + const config = loadConfig(source).parse(); + config.removeEntryFromArray(['addons'], 'b'); + expect(config.getFieldValue(['addons'])).toMatchInlineSnapshot(`a,c`); + }); + + it('removes a pnp-wrapped string entry', () => { + const source = dedent` + export default { + addons: ['a', getAbsolutePath('b'), 'c'], + } + `; + const config = loadConfig(source).parse(); + config.removeEntryFromArray(['addons'], 'b'); + expect(config.getFieldValue(['addons'])).toMatchInlineSnapshot(`a,c`); + }); + + it('removes a pnp-wrapped object entry', () => { + const source = dedent` + export default { + addons: ['a', { name: getAbsolutePath('b'), options: {} }, 'c'], + } + `; + const config = loadConfig(source).parse(); + config.removeEntryFromArray(['addons'], 'b'); + expect(config.getFieldValue(['addons'])).toMatchInlineSnapshot(`a,c`); + }); + + it('throws when entry is missing', () => { + const source = dedent` + export default { + addons: ['a', { name: 'b', options: {} }, 'c'], + } + `; + const config = loadConfig(source).parse(); + expect(() => config.removeEntryFromArray(['addons'], 'x')).toThrowErrorMatchingInlineSnapshot( + `Error: Could not find 'x' in array at 'addons'` + ); + }); + + it('throws when target array is not an arral', () => { + const source = dedent` + export default { + addons: {}, + } + `; + const config = loadConfig(source).parse(); + expect(() => config.removeEntryFromArray(['addons'], 'x')).toThrowErrorMatchingInlineSnapshot( + `Error: Expected array at 'addons', got 'ObjectExpression'` + ); + }); + }); }); diff --git a/code/lib/csf-tools/src/ConfigFile.ts b/code/lib/csf-tools/src/ConfigFile.ts index eb2921b4e06d..410bbcc1fee7 100644 --- a/code/lib/csf-tools/src/ConfigFile.ts +++ b/code/lib/csf-tools/src/ConfigFile.ts @@ -390,6 +390,16 @@ export class ConfigFile { return pathNames; } + _getPnpWrappedValue(node: t.Node) { + if (t.isCallExpression(node)) { + const arg = node.arguments[0]; + if (t.isStringLiteral(arg)) { + return arg.value; + } + } + return undefined; + } + /** * Given a node and a fallback property, returns a **non-evaluated** string value of the node. * 1. { node: 'value' } @@ -409,6 +419,8 @@ export class ConfigFile { ) { if (t.isStringLiteral(prop.value)) { value = prop.value.value; + } else { + value = this._getPnpWrappedValue(prop.value); } } @@ -506,6 +518,34 @@ export class ConfigFile { } } + /** + * Specialized helper to remove addons or other array entries + * that can either be strings or objects with a name property. + */ + removeEntryFromArray(path: string[], value: string) { + const current = this.getFieldNode(path); + if (!current) return; + if (t.isArrayExpression(current)) { + const index = current.elements.findIndex((element) => { + if (t.isStringLiteral(element)) { + return element.value === value; + } + if (t.isObjectExpression(element)) { + const name = this._getPresetValue(element, 'name'); + return name === value; + } + return this._getPnpWrappedValue(element as t.Node) === value; + }); + if (index >= 0) { + current.elements.splice(index, 1); + } else { + throw new Error(`Could not find '${value}' in array at '${path.join('.')}'`); + } + } else { + throw new Error(`Expected array at '${path.join('.')}', got '${current.type}'`); + } + } + _inferQuotes() { if (!this._quotes) { // first 500 tokens for efficiency