diff --git a/package.json b/package.json index f04c982621..8a6c58618f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint:all": "lerna run lint --", "docker-build": "yarn bundle && docker build . -t spotify/backstage", "create-plugin": "backstage-cli create-plugin", + "remove-plugin": "backstage-cli remove-plugin", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi", "lerna": "lerna", "storybook": "yarn workspace storybook start" diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index f1c0230e09..1af5832818 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -65,10 +65,13 @@ const sortObjectByKeys = (obj: { [name in string]: string }) => { }, {} as { [name in string]: string }); }; -const capitalize = (str: string): string => +export const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); -const addExportStatement = async (file: string, exportStatement: string) => { +export const addExportStatement = async ( + file: string, + exportStatement: string, +) => { const newContents = fs .readFileSync(file, 'utf8') .split('\n') diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts new file mode 100644 index 0000000000..1cd47864c6 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fse from 'fs-extra'; +import path from 'path'; +import os from 'os'; +import { paths } from '../../helpers/paths'; +import { + addExportStatement, + capitalize, + createTemporaryPluginFolder, +} from '../create-plugin/createPlugin'; +import { addCodeownersEntry } from '../create-plugin/lib/codeowners'; +import { + removeReferencesFromAppPackage, + removeReferencesFromPluginsFile, + removePluginDirectory, + removeSymLink, + removePluginFromCodeOwners, +} from './removePlugin'; + +// Some constant variables +const BACKSTAGE = `@backstage`; +const testPluginName = 'yarn-test-package'; +const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; +const tempDir = path.join(os.tmpdir(), 'remove-plugin-test'); + +const removeEmptyLines = (file: string): string => + file + .split('\n') + .filter(Boolean) + .join('\n'); + +const createTestPackageFile = async ( + testFilePath: string, + packageFile: string, +) => { + // Copy contents of package file for test + const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + + packageFileContent.dependencies[testPluginPackage] = '0.1.0'; + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContent, null, 2)}\n`, + 'utf8', + ); + return; +}; + +const createTestPluginFile = async ( + testFilePath: string, + pluginsFilePath: string, +) => { + // Copy contents of package file for test + fse.copyFileSync(pluginsFilePath, testFilePath); + const pluginNameCapitalized = testPluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + addExportStatement(testFilePath, exportStatement); +}; + +const mkTestPluginDir = (testDirPath: string) => { + fse.mkdirSync(testDirPath); + for (let i = 0; i < 50; i++) + fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); +}; + +beforeAll(() => { + // Create temporary directory for all tests + createTemporaryPluginFolder(tempDir); +}); + +describe('removePlugin', () => { + describe('Remove Plugin Dependencies', () => { + const appPath = paths.resolveTargetRoot('packages', 'app'); + const githubDir = paths.resolveTargetRoot('.github'); + it('removes plugin references from /packages/app/package.json', async () => { + // Set up test + const packageFilePath = path.join(appPath, 'package.json'); + const testFilePath = path.join(tempDir, 'test.json'); + createTestPackageFile(testFilePath, packageFilePath); + try { + await removeReferencesFromAppPackage(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const packageFileContent = removeEmptyLines( + fse.readFileSync(packageFilePath, 'utf8'), + ); + expect(testFileContent === packageFileContent).toBe(true); + } finally { + fse.removeSync(testFilePath); + } + }); + it('removes plugin exports from /packages/app/src/packacge.json', async () => { + const testFilePath = path.join(tempDir, 'test.ts'); + const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); + createTestPluginFile(testFilePath, pluginsFilePaths); + try { + await removeReferencesFromPluginsFile(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const pluginsFileContent = removeEmptyLines( + fse.readFileSync(pluginsFilePaths, 'utf8'), + ); + expect(testFileContent === pluginsFileContent).toBe(true); + } finally { + fse.removeSync(testFilePath); + } + }); + it('removes codeOwners references', async () => { + const testFilePath = path.join(tempDir, 'test'); + const codeownersPath = path.join(githubDir, 'CODEOWNERS'); + try { + fse.copySync(codeownersPath, testFilePath); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const codeOwnersFileContent = removeEmptyLines( + fse.readFileSync(codeownersPath, 'utf8'), + ); + await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [ + '@thisIsAtestTeam', + 'test@gmail.com', + ]); + await removePluginFromCodeOwners(testFilePath, testPluginName); + expect(testFileContent === codeOwnersFileContent).toBeTruthy(); + } finally { + if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); + } + }); + }); + describe('Remove files', () => { + const testDirPath = path.join( + paths.resolveTargetRoot(), + 'plugins', + testPluginName, + ); + describe('Removes Plugin Directory', () => { + it('removes plugin directory from /plugins', async () => { + try { + mkTestPluginDir(testDirPath); + expect(fse.existsSync(testDirPath)).toBeTruthy(); + await removePluginDirectory(testDirPath); + expect(fse.existsSync(testDirPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + } + }); + }); + describe('Removes System Link', () => { + it('removes system link from @backstage', async () => { + const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); + const testSymLinkPath = path.join( + scopedDir, + `plugin-${testPluginName}`, + ); + try { + mkTestPluginDir(testDirPath); + fse.ensureSymlinkSync(testSymLinkPath, testDirPath); + + await removeSymLink(testSymLinkPath); + expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath); + } + }); + }); + }); +}); + +afterAll(() => { + // Remove temporary directory + fse.removeSync(tempDir); +}); diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts new file mode 100644 index 0000000000..76b8606973 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -0,0 +1,257 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import fse from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; +import inquirer, { Answers, Question } from 'inquirer'; +import { getCodeownersFilePath } from '../create-plugin/lib/codeowners'; +import { paths } from 'helpers/paths'; +import { Task } from 'helpers/tasks'; +// import os from 'os'; + +const BACKSTAGE = '@backstage'; + +export const checkExists = async (rootDir: string, pluginName: string) => { + await Task.forItem('checking', pluginName, async () => { + try { + const destination = path.join(rootDir, 'plugins', pluginName); + const pathExist = await fse.pathExists(destination); + + if (!pathExist) { + throw new Error( + chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`), + ); + } + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing plugin ${chalk.cyan(pluginName)}: ${ + e.message + }`, + ), + ); + } + }); +}; + +export const removePluginDirectory = async (destination: string) => { + await Task.forItem('removing', 'plugin files', async () => { + try { + await fse.remove(destination); + } catch (e) { + throw Error( + chalk.red( + ` There was a problem removing the plugin directory: ${e.message}`, + ), + ); + } + }); +}; + +export const removeSymLink = async (destination: string) => { + await Task.forItem('removing', 'symbolic link', async () => { + const symLinkExists = fse.pathExists(destination); + if (symLinkExists) { + try { + await fse.remove(destination); + } catch (e) { + throw Error( + chalk.red( + ` Could not remove symbolic link\t${chalk.cyan(destination)}: ${ + e.message + }`, + ), + ); + } + } + }); +}; + +const removeAllStatementsContainingID = async (file: string, ID: string) => { + const originalContent = await fse.readFile(file, 'utf8'); + const contentAfterRemoval = originalContent + .split('\n') + .filter(Boolean) // get rid of empty lines + .filter(statement => { + return !statement.includes(`${ID}`); + }) // get rid of lines with pluginName + .concat(['']) // newline at end of line + .join('\n'); + await fse.writeFile(file, contentAfterRemoval, 'utf8'); + const finalContent = await fse.readFile(file, 'utf8'); + if (finalContent === originalContent) + throw new Error(`File was not modified.`); +}; + +const capitalize = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); + +export const removeReferencesFromPluginsFile = async ( + pluginsFile: string, + pluginName: string, +) => { + const pluginNameCapitalized = pluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + + await Task.forItem('removing', 'export references', async () => { + try { + await removeAllStatementsContainingID(pluginsFile, pluginNameCapitalized); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing export statement for plugin ${chalk.cyan( + pluginNameCapitalized, + )}: ${e.message}`, + ), + ); + } + }); +}; + +export const removePluginFromCodeOwners = async ( + codeOwnersFile: string, + pluginName: string, +) => { + await Task.forItem('removing', 'codeowners references', async () => { + try { + await removeAllStatementsContainingID(codeOwnersFile, pluginName); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing code owners statement for plugin ${chalk.cyan( + pluginName, + )}: ${e.message}`, + ), + ); + } + }); +}; + +export const removeReferencesFromAppPackage = async ( + appPackageFile: string, + pluginName: string, +) => { + const pluginPackage = `${BACKSTAGE}/plugin-${pluginName}`; + + await Task.forItem('removing', 'plugin app dependency', async () => { + try { + const appPackageFileContent = await fse.readFile(appPackageFile, 'utf-8'); + const appPackageFileContentJSON = JSON.parse(appPackageFileContent); + const dependencies = appPackageFileContentJSON.dependencies; + + if (!dependencies[pluginPackage]) { + throw new Error( + chalk.red( + ` Plugin ${chalk.cyan( + pluginPackage, + )} does not exist in ${chalk.cyan(appPackageFile)}`, + ), + ); + } + + delete dependencies[pluginPackage]; + await fse.writeFile( + appPackageFile, + `${JSON.stringify(appPackageFileContentJSON, null, 2)}\n`, + 'utf-8', + ); + } catch (e) { + throw new Error( + chalk.red( + ` Failed to remove plugin as dependency in app: ${chalk.cyan( + appPackageFile, + )}: ${e.message}`, + ), + ); + } + }); +}; + +export default async () => { + const questions: Question[] = [ + { + type: 'input', + name: 'pluginName', + message: chalk.blue( + 'Enter the ID of the plugin to be removed [required]', + ), + validate: (value: any) => { + if (!value) { + return chalk.red('Please enter an ID for the plugin'); + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return chalk.red( + 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.', + ); + } + return true; + }, + }, + ]; + + const answers: Answers = await inquirer.prompt(questions); + const pluginName: string = answers.pluginName; + const appPackage = paths.resolveTargetRoot('packages/app'); + const pluginDir = paths.resolveTargetRoot('plugins', answers.pluginName); + const codeOwnersFile = await getCodeownersFilePath(paths.targetRoot); + const appPackageFile = path.join(appPackage, 'package.json'); + const appPluginsFile = path.join(appPackage, 'src', 'plugins.ts'); + const pluginScopedDirectory = paths.resolveTargetRoot( + 'node_modules', + BACKSTAGE, + `plugin-${pluginName}`, + ); + + Task.log(); + Task.log('Removing the plugin...'); + + console.log(pluginScopedDirectory); + try { + Task.section('Checking the plugin exists.'); + await checkExists(paths.targetRoot, pluginName); + + Task.section('Removing plugin files.'); + await removePluginDirectory(pluginDir); + + Task.section('Removing symbolic link from @backstage.'); + await removeSymLink(pluginScopedDirectory); + + if (await fse.pathExists(appPackage)) { + Task.section('Removing references from plugins.ts.'); + await removeReferencesFromPluginsFile(appPluginsFile, pluginName); + + Task.section('Removing plugin dependency from app.'); + await removeReferencesFromAppPackage(appPackageFile, pluginName); + } + + if (codeOwnersFile) { + Task.section('Removing codeowners reference.'); + await removePluginFromCodeOwners(codeOwnersFile, pluginName); + } + + Task.log(); + Task.log( + `🥇 Successfully removed ${chalk.cyan( + `@backstage/plugin-${answers.id}`, + )}`, + ); + Task.log(); + } catch (error) { + Task.error(error.message); + Task.log('It seems that something went wrong when removing the plugin 🤔'); + } +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e90901b22a..3ad43faa76 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -44,6 +44,13 @@ const main = (argv: string[]) => { actionHandler(() => require('commands/create-plugin/createPlugin')), ); + program + .command('remove-plugin') + .description('Removes plugin in the current repository') + .action( + actionHandler(() => require('commands/remove-plugin/removePlugin')), + ); + program .command('plugin:build') .option('--watch', 'Enable watch mode')