From 1ef9e649014ecd284b226674e3ed4dc857fb3b57 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 17 Sep 2021 16:35:30 +0200 Subject: [PATCH 1/4] cli: add install command Co-authored-by: Patrik Oldsberg Signed-off-by: Himanshu Mishra --- .changeset/spicy-goats-help.md | 7 + packages/cli/src/commands/index.ts | 5 + packages/cli/src/commands/install/install.ts | 197 +++++++++++++++++++ packages/cli/src/commands/install/types.ts | 64 ++++++ packages/cli/src/lib/versioning/index.ts | 1 + packages/cli/src/lib/versioning/packages.ts | 2 +- 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .changeset/spicy-goats-help.md create mode 100644 packages/cli/src/commands/install/install.ts create mode 100644 packages/cli/src/commands/install/types.ts 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[]; From c8215e1fe69224d93e977c07565a85a4c5b8728f Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 17 Sep 2021 16:37:03 +0200 Subject: [PATCH 2/4] graphiql: Add experimental installationRecipe to package.json Used by the new experimental `backstage-cli install ` command Co-authored-by: Patrik Oldsberg Signed-off-by: Himanshu Mishra --- .changeset/honest-drinks-eat.md | 5 +++++ plugins/graphiql/package.json | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/honest-drinks-eat.md diff --git a/.changeset/honest-drinks-eat.md b/.changeset/honest-drinks-eat.md new file mode 100644 index 0000000000..8d997acb61 --- /dev/null +++ b/.changeset/honest-drinks-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Add experimental `installationRecipe` to `package.json`. diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 56fd991ec9..ffb238bd51 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -60,5 +60,22 @@ }, "files": [ "dist" - ] + ], + "installationRecipe": { + "type": "frontend-plugin", + "steps": [ + { + "type": "app-route", + "path": "/graphiql", + "element": "" + }, + { + "type": "message", + "message": [ + "The GraphiQL plugin has been installed, but you still need to add API endpoints. ", + "See https://github.com/backstage/backstage/tree/master/plugins/graphiql#adding-graphql-endpoints" + ] + } + ] + } } From d9dde3129e13719cc62f3c7e30662a5b8d6af7db Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 17 Sep 2021 17:38:46 +0200 Subject: [PATCH 3/4] cli: refactor plugin installation steps into separate modules Co-authored-by: Patrik Oldsberg Signed-off-by: Himanshu Mishra --- packages/cli/src/commands/install/install.ts | 141 ++++-------------- .../src/commands/install/steps/appRoute.ts | 92 ++++++++++++ .../commands/install/steps/dependencies.ts | 82 ++++++++++ .../cli/src/commands/install/steps/index.ts | 19 +++ .../cli/src/commands/install/steps/message.ts | 48 ++++++ packages/cli/src/commands/install/types.ts | 53 ++++--- 6 files changed, 295 insertions(+), 140 deletions(-) create mode 100644 packages/cli/src/commands/install/steps/appRoute.ts create mode 100644 packages/cli/src/commands/install/steps/dependencies.ts create mode 100644 packages/cli/src/commands/install/steps/index.ts create mode 100644 packages/cli/src/commands/install/steps/message.ts 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; +} From 632a6b217822f881fef7c1fdaa04545fb5f849a9 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 7 Oct 2021 16:32:00 +0200 Subject: [PATCH 4/4] cli: use experimentalInstallationRecipe for new backstage-cli install command Signed-off-by: Himanshu Mishra --- .changeset/honest-drinks-eat.md | 2 +- .changeset/spicy-goats-help.md | 2 +- packages/cli/src/commands/install/install.ts | 2 +- packages/cli/src/commands/install/types.ts | 2 +- plugins/graphiql/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/honest-drinks-eat.md b/.changeset/honest-drinks-eat.md index 8d997acb61..b6cb168e00 100644 --- a/.changeset/honest-drinks-eat.md +++ b/.changeset/honest-drinks-eat.md @@ -2,4 +2,4 @@ '@backstage/plugin-graphiql': patch --- -Add experimental `installationRecipe` to `package.json`. +Add experimental `experimentalInstallationRecipe` to `package.json`. diff --git a/.changeset/spicy-goats-help.md b/.changeset/spicy-goats-help.md index 6c040ff6c9..44f1e96332 100644 --- a/.changeset/spicy-goats-help.md +++ b/.changeset/spicy-goats-help.md @@ -4,4 +4,4 @@ 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). +Given a `pluginId`, the command looks for NPM packages matching `@backstage/plugin-{pluginId}` or `backstage-plugin-{pluginId}` or `{pluginId}`. It looks for the `experimentalInstallationRecipe` 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/install/install.ts b/packages/cli/src/commands/install/install.ts index 6117110f6e..2ec208bca3 100644 --- a/packages/cli/src/commands/install/install.ts +++ b/packages/cli/src/commands/install/install.ts @@ -67,7 +67,7 @@ class PluginInstaller { step: stepDefinitionMap.dependencies.create({ dependencies }), }); - for (const step of pkg.installationRecipe?.steps ?? []) { + for (const step of pkg.experimentalInstallationRecipe?.steps ?? []) { const { type } = step; const definition = stepDefinitions.find(d => d.type === type); diff --git a/packages/cli/src/commands/install/types.ts b/packages/cli/src/commands/install/types.ts index c75d147cfb..5479032de5 100644 --- a/packages/cli/src/commands/install/types.ts +++ b/packages/cli/src/commands/install/types.ts @@ -43,7 +43,7 @@ export type InstallationRecipe = { /** package.json data */ export type PackageWithInstallRecipe = YarnInfoInspectData & { version: string; - installationRecipe?: InstallationRecipe; + experimentalInstallationRecipe?: InstallationRecipe; }; export interface Step { diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index ffb238bd51..b2523bb3c1 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -61,7 +61,7 @@ "files": [ "dist" ], - "installationRecipe": { + "experimentalInstallationRecipe": { "type": "frontend-plugin", "steps": [ {