diff --git a/packages/cli/src/commands/install/install.ts b/packages/cli/src/commands/install/install.ts index 7e46dd4cf0..6117110f6e 100644 --- a/packages/cli/src/commands/install/install.ts +++ b/packages/cli/src/commands/install/install.ts @@ -14,21 +14,12 @@ * 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 { Step, PackageWithInstallRecipe } from './types'; import { fetchPackageInfo } from '../../lib/versioning'; import { NotFoundError } from '../../lib/errors'; -import { paths } from '../../lib/paths'; -import { run } from '../../lib/run'; +import * as stepDefinitionMap from './steps'; + +const stepDefinitions = Object.values(stepDefinitionMap); async function fetchPluginPackage( id: string, @@ -53,9 +44,14 @@ async function fetchPluginPackage( ); } +type Steps = Array<{ + type: string; + step: Step; +}>; + class PluginInstaller { static async resolveSteps(pkg: PackageWithInstallRecipe) { - const steps = new Array(); + const steps: Steps = []; // collectDependencies // TODO: Deps mean the plugin package itself, and any other backstage plugins/packages it depends on, in its installation recipe. @@ -68,130 +64,43 @@ class PluginInstaller { }); steps.push({ type: 'dependencies', - dependencies, + step: stepDefinitionMap.dependencies.create({ dependencies }), }); - // TODO(Rugvip): validate input for (const step of pkg.installationRecipe?.steps ?? []) { - if (step.type === 'app-route') { + const { type } = step; + + const definition = stepDefinitions.find(d => d.type === type); + if (definition) { steps.push({ - ...step, - packageName: pkg.name, + type, + step: definition.deserialize(step, pkg), }); - } else if (step.type === 'message') { - steps.push(step); } else { - throw new Error(`Unsupported step type: ${step.type}`); + throw new Error(`Unsupported step type: ${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('')); - } + constructor(private readonly steps: Steps) {} async run() { - for (const step of this.steps) { + for (const { type, 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); - } + console.log(`Running step ${type}`); + await step.run(); } } } export default async (pluginId: string) => { // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? + // TODO(himanshu): Add a way to test your install recipe. Maybe a --from-local-package=/path/to/package.json const pkg = await fetchPluginPackage(pluginId); - const Steps = await PluginInstaller.resolveSteps(pkg); - const installer = new PluginInstaller(Steps); + const steps = await PluginInstaller.resolveSteps(pkg); + const installer = new PluginInstaller(steps); await installer.run(); }; diff --git a/packages/cli/src/commands/install/steps/appRoute.ts b/packages/cli/src/commands/install/steps/appRoute.ts new file mode 100644 index 0000000000..f89b7eedff --- /dev/null +++ b/packages/cli/src/commands/install/steps/appRoute.ts @@ -0,0 +1,92 @@ +/* + * 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 { paths } from '../../../lib/paths'; +import { Step, createStepDefinition } from '../types'; + +type Data = { + path: string; + element: string; + packageName: string; +}; + +class AppRouteStep implements Step { + constructor(private readonly data: Data) {} + + async run() { + const { path, element, packageName } = this.data; + + 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 = element.match(/[A-Za-z0-9]+/)?.[0]; + if (!componentName) { + throw new Error(`Could not find component name in ${element}`); + } + + // Add plugin import + // TODO(Rugvip): Attempt to add this among the other plugin imports + const contentsWithImport = contentsWithRoute.replace( + /^import /m, + `import { ${componentName} } from '${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 '${packageName}';`); + console.log(`2. `); + } else { + await fs.writeFile(appTsxPath, contentsWithImport); + } + } +} + +export const appRoute = createStepDefinition({ + type: 'app-route', + + deserialize(obj, pkg) { + const { path, element } = obj; + if (!path || typeof path !== 'string') { + throw new Error("Invalid install step, 'path' must be a string"); + } + if (!element || typeof element !== 'string') { + throw new Error("Invalid install step, 'element' must be a string"); + } + return new AppRouteStep({ path, element, packageName: pkg.name }); + }, + + create(data: Data) { + return new AppRouteStep(data); + }, +}); diff --git a/packages/cli/src/commands/install/steps/dependencies.ts b/packages/cli/src/commands/install/steps/dependencies.ts new file mode 100644 index 0000000000..4381066159 --- /dev/null +++ b/packages/cli/src/commands/install/steps/dependencies.ts @@ -0,0 +1,82 @@ +/* + * 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 { paths } from '../../../lib/paths'; +import { run } from '../../../lib/run'; +import { Step, createStepDefinition } from '../types'; + +type Data = { + dependencies: Array<{ + target: string; + type: 'dependencies'; + name: string; + query: string; + }>; +}; + +class DependenciesStep implements Step { + constructor(private readonly data: Data) {} + + async run() { + const { dependencies } = this.data; + // yarn --cwd packages/app add + const byTarget = groupBy(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']); + } +} + +export const dependencies = createStepDefinition({ + type: 'dependencies', + + deserialize() { + throw new Error('The dependency step may not be defined in JSON'); + }, + + create(data: Data) { + return new DependenciesStep(data); + }, +}); diff --git a/packages/cli/src/commands/install/steps/index.ts b/packages/cli/src/commands/install/steps/index.ts new file mode 100644 index 0000000000..590fba120f --- /dev/null +++ b/packages/cli/src/commands/install/steps/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export { appRoute } from './appRoute'; +export { dependencies } from './dependencies'; +export { message } from './message'; diff --git a/packages/cli/src/commands/install/steps/message.ts b/packages/cli/src/commands/install/steps/message.ts new file mode 100644 index 0000000000..5821c7d98b --- /dev/null +++ b/packages/cli/src/commands/install/steps/message.ts @@ -0,0 +1,48 @@ +/* + * 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 { Step, createStepDefinition } from '../types'; + +type Data = { + message: string; +}; + +class MessageStep implements Step { + constructor(private readonly data: Data) {} + + async run() { + console.log(this.data.message); + } +} + +export const message = createStepDefinition({ + type: 'message', + + deserialize(obj) { + const { message: msg } = obj; + + if (!msg || (typeof msg !== 'string' && !Array.isArray(msg))) { + throw new Error( + "Invalid install step, 'message' must be a string or array", + ); + } + return new MessageStep({ message: [msg].flat().join('') }); + }, + + create(data: Data) { + return new MessageStep(data); + }, +}); diff --git a/packages/cli/src/commands/install/types.ts b/packages/cli/src/commands/install/types.ts index f9ab8426ca..c75d147cfb 100644 --- a/packages/cli/src/commands/install/types.ts +++ b/packages/cli/src/commands/install/types.ts @@ -15,6 +15,7 @@ */ import { YarnInfoInspectData } from '../../lib/versioning'; +import { JsonObject } from '@backstage/config'; /** * TODO: possible types @@ -29,36 +30,40 @@ import { YarnInfoInspectData } from '../../lib/versioning'; * 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; +/** A serialized install step as it appears in JSON */ +export type SerializedStep = { + type: string; +} & unknown; export type InstallationRecipe = { type?: 'frontend' | 'backend'; - steps: Step[]; + steps: SerializedStep[]; }; +/** package.json data */ export type PackageWithInstallRecipe = YarnInfoInspectData & { version: string; installationRecipe?: InstallationRecipe; }; + +export interface Step { + run(): Promise; +} + +export interface StepDefinition { + /** The string identifying this type of step */ + type: string; + + /** Deserializes and validate a JSON description of the step data */ + deserialize(obj: JsonObject, pkg: PackageWithInstallRecipe): Step; + + /** Creates a step using known parameters */ + create(options: Options): Step; +} + +/** Creates a new step definition. Only used as a helper for type inference */ +export function createStepDefinition( + config: StepDefinition, +): StepDefinition { + return config; +}