diff --git a/.changeset/spicy-goats-help.md b/.changeset/spicy-goats-help.md new file mode 100644 index 0000000000..6c040ff6c9 --- /dev/null +++ b/.changeset/spicy-goats-help.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +Add an experimental `install ` command. + +Given a `pluginId`, the command looks for NPM packages matching `@backstage/plugin-{pluginId}` or `backstage-plugin-{pluginId}` or `{pluginId}`. It looks for the `installationRecipe` in their `package.json` for the steps of installation. Detailed documentation and API Spec to follow (and to be decided as well). diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index bcd1509f58..6dbe42feb1 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -229,6 +229,11 @@ export function registerCommands(program: CommanderStatic) { .command('info') .description('Show helpful information for debugging and reporting bugs') .action(lazy(() => import('./info').then(m => m.default))); + + program + .command('install ', { hidden: true }) + .description('Install a Backstage plugin [EXPERIMENTAL]') + .action(lazy(() => import('./install/install').then(m => m.default))); } // Wraps an action function so that it always exits and handles errors diff --git a/packages/cli/src/commands/install/install.ts b/packages/cli/src/commands/install/install.ts new file mode 100644 index 0000000000..7e46dd4cf0 --- /dev/null +++ b/packages/cli/src/commands/install/install.ts @@ -0,0 +1,197 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 fs from 'fs-extra'; +import chalk from 'chalk'; +import sortBy from 'lodash/sortBy'; +import groupBy from 'lodash/groupBy'; +import { + Step, + StepAppRoute, + StepMessage, + StepDependencies, + PackageWithInstallRecipe, +} from './types'; +import { fetchPackageInfo } from '../../lib/versioning'; +import { NotFoundError } from '../../lib/errors'; +import { paths } from '../../lib/paths'; +import { run } from '../../lib/run'; + +async function fetchPluginPackage( + id: string, +): Promise { + const searchNames = [`@backstage/plugin-${id}`, `backstage-plugin-${id}`, id]; + + for (const name of searchNames) { + try { + const packageInfo = (await fetchPackageInfo( + name, + )) as PackageWithInstallRecipe; + return packageInfo; + } catch (error) { + if (error.name !== 'NotFoundError') { + throw error; + } + } + } + + throw new NotFoundError( + `No matching package found for '${id}', tried ${searchNames.join(', ')}`, + ); +} + +class PluginInstaller { + static async resolveSteps(pkg: PackageWithInstallRecipe) { + const steps = new Array(); + + // collectDependencies + // TODO: Deps mean the plugin package itself, and any other backstage plugins/packages it depends on, in its installation recipe. + const dependencies = []; + dependencies.push({ + target: 'packages/app', + type: 'dependencies' as const, + name: pkg.name, + query: `^${pkg.version}`, + }); + steps.push({ + type: 'dependencies', + dependencies, + }); + + // TODO(Rugvip): validate input + for (const step of pkg.installationRecipe?.steps ?? []) { + if (step.type === 'app-route') { + steps.push({ + ...step, + packageName: pkg.name, + }); + } else if (step.type === 'message') { + steps.push(step); + } else { + throw new Error(`Unsupported step type: ${step.type}`); + } + } + + return steps; + } + + constructor(private readonly steps: Step[]) {} + + /** + * Updates package.json files with the dependencies and devDependencies. + */ + private async stepDependencies(step: StepDependencies) { + // yarn --cwd packages/app add + const byTarget = groupBy(step.dependencies, 'target'); + + // Go through each target package and install the dependencies. + for (const [target, deps] of Object.entries(byTarget)) { + const pkgPath = paths.resolveTargetRoot(target, 'package.json'); + const pkgJson = await fs.readJson(pkgPath); + + // Populate each type of dependency object, dependencies, devDependencies, etc. + const depTypes = new Set(); + for (const dep of deps) { + depTypes.add(dep.type); + pkgJson[dep.type][dep.name] = dep.query; + } + + // Be nice and sort the dependencies alphabetically + for (const depType of depTypes) { + pkgJson[depType] = Object.fromEntries( + sortBy(Object.entries(pkgJson[depType]), ([key]) => key), + ); + } + await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); + } + + console.log(); + console.log( + `Running ${chalk.blue('yarn install')} to install new versions`, + ); + console.log(); + await run('yarn', ['install']); + } + + private async stepAppRoute(step: StepAppRoute) { + const appTsxPath = paths.resolveTargetRoot('packages/app/src/App.tsx'); + const contents = await fs.readFile(appTsxPath, 'utf-8'); + let failed = false; + + // Add a new route just above the end of the FlatRoutes block + const contentsWithRoute = contents.replace( + /(\s*)<\/FlatRoutes>/, + `$1 $1`, + ); + if (contentsWithRoute === contents) { + failed = true; + } + + // Grab the component name from the element + const componentName = step.element.match(/[A-Za-z0-9]+/)?.[0]; + if (!componentName) { + throw new Error(`Could not find component name in ${step.element}`); + } + + // Add plugin import + // TODO(Rugvip): Attempt to add this among the other plugin imports + const contentsWithImport = contentsWithRoute.replace( + /^import /m, + `import { ${componentName} } from '${step.packageName}';\nimport `, + ); + if (contentsWithImport === contentsWithRoute) { + failed = true; + } + + if (failed) { + console.log( + 'Failed to automatically add a route to package/app/src/App.tsx', + ); + console.log(`Action needed, add the following:`); + console.log(`1. import { ${componentName} } from '${step.packageName}';`); + console.log(`2. `); + } else { + await fs.writeFile(appTsxPath, contentsWithImport); + } + } + + private async stepMessage(step: StepMessage) { + console.log([step.message].flat().join('')); + } + + async run() { + for (const step of this.steps) { + // TODO(Rugvip): Add spinners, nicer message about the step. + console.log(`Running step ${step.type}`); + if (step.type === 'dependencies') { + await this.stepDependencies(step); + } else if (step.type === 'app-route') { + await this.stepAppRoute(step); + } else if (step.type === 'message') { + await this.stepMessage(step); + } + } + } +} + +export default async (pluginId: string) => { + // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? + + const pkg = await fetchPluginPackage(pluginId); + const Steps = await PluginInstaller.resolveSteps(pkg); + const installer = new PluginInstaller(Steps); + await installer.run(); +}; diff --git a/packages/cli/src/commands/install/types.ts b/packages/cli/src/commands/install/types.ts new file mode 100644 index 0000000000..f9ab8426ca --- /dev/null +++ b/packages/cli/src/commands/install/types.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { YarnInfoInspectData } from '../../lib/versioning'; + +/** + * TODO: possible types + * + * frontend-deps: Install one or many frontend packages in a Backstage app + * backend-deps: Install one or many backend packages in a Backstage app + * app-config: Update app-config.yaml (and ask for inputs). E.g. Use local or docker for techdocs.builder + * frontend-route: Add a frontend route to the plugin homepage + * backend-route: Add a backend route to the plugin + * entity-page-tab: Add a tab on Catalog’s entity page + * sidebar-item: Add a sidebar item + * frontend-api: Add a custom API + */ + +export type StepAppRoute = { + type: 'app-route'; + path: string; + element: string; + packageName: string; +}; + +export type StepMessage = { + type: 'message'; + message: string | string[]; +}; + +export type StepDependencies = { + type: 'dependencies'; + dependencies: Array<{ + target: string; + type: 'dependencies'; + name: string; + query: string; + }>; +}; + +export type Step = StepAppRoute | StepMessage | StepDependencies; + +export type InstallationRecipe = { + type?: 'frontend' | 'backend'; + steps: Step[]; +}; + +export type PackageWithInstallRecipe = YarnInfoInspectData & { + version: string; + installationRecipe?: InstallationRecipe; +}; diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli/src/lib/versioning/index.ts index fb3b8989b4..e0b9280ee6 100644 --- a/packages/cli/src/lib/versioning/index.ts +++ b/packages/cli/src/lib/versioning/index.ts @@ -16,3 +16,4 @@ export { Lockfile } from './Lockfile'; export { fetchPackageInfo, mapDependencies } from './packages'; +export type { YarnInfoInspectData } from './packages'; diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 6ed584a249..9f82c560f8 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -27,7 +27,7 @@ const DEP_TYPES = [ ]; // Package data as returned by `yarn info` -type YarnInfoInspectData = { +export type YarnInfoInspectData = { name: string; 'dist-tags': { latest: string }; versions: string[];