Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Add addon remove command #25538

Merged
merged 3 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions code/lib/cli/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,14 @@ command('add <addon>')
.option('-s --skip-postinstall', 'Skip package specific postinstall config modifications')
.action((addonName: string, options: any) => add(addonName, options));

command('remove <addon>')
.description('Remove an addon from your Storybook')
.option(
'--package-manager <npm|pnpm|yarn1|yarn2>',
'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(
Expand Down
46 changes: 46 additions & 0 deletions code/lib/cli/src/remove.ts
Original file line number Diff line number Diff line change
@@ -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.`);
}
}
70 changes: 70 additions & 0 deletions code/lib/csf-tools/src/ConfigFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'`
);
});
});
});
40 changes: 40 additions & 0 deletions code/lib/csf-tools/src/ConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -409,6 +419,8 @@ export class ConfigFile {
) {
if (t.isStringLiteral(prop.value)) {
value = prop.value.value;
} else {
value = this._getPnpWrappedValue(prop.value);
}
}

Expand Down Expand Up @@ -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) {
shilman marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
Loading