diff --git a/.changeset/honest-drinks-eat.md b/.changeset/honest-drinks-eat.md new file mode 100644 index 0000000000..b6cb168e00 --- /dev/null +++ b/.changeset/honest-drinks-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Add experimental `experimentalInstallationRecipe` to `package.json`. diff --git a/.changeset/spicy-goats-help.md b/.changeset/spicy-goats-help.md new file mode 100644 index 0000000000..44f1e96332 --- /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 `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/index.ts b/packages/cli/src/commands/index.ts index 117778b35e..1fef399501 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -230,6 +230,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..2ec208bca3 --- /dev/null +++ b/packages/cli/src/commands/install/install.ts @@ -0,0 +1,106 @@ +/* + * 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, PackageWithInstallRecipe } from './types'; +import { fetchPackageInfo } from '../../lib/versioning'; +import { NotFoundError } from '../../lib/errors'; +import * as stepDefinitionMap from './steps'; + +const stepDefinitions = Object.values(stepDefinitionMap); + +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(', ')}`, + ); +} + +type Steps = Array<{ + type: string; + step: Step; +}>; + +class PluginInstaller { + static async resolveSteps(pkg: PackageWithInstallRecipe) { + const steps: Steps = []; + + // 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', + step: stepDefinitionMap.dependencies.create({ dependencies }), + }); + + for (const step of pkg.experimentalInstallationRecipe?.steps ?? []) { + const { type } = step; + + const definition = stepDefinitions.find(d => d.type === type); + if (definition) { + steps.push({ + type, + step: definition.deserialize(step, pkg), + }); + } else { + throw new Error(`Unsupported step type: ${type}`); + } + } + + return steps; + } + + constructor(private readonly steps: Steps) {} + + async run() { + for (const { type, step } of this.steps) { + // TODO(Rugvip): Add spinners, nicer message about the 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); + 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 new file mode 100644 index 0000000000..5479032de5 --- /dev/null +++ b/packages/cli/src/commands/install/types.ts @@ -0,0 +1,69 @@ +/* + * 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'; +import { JsonObject } from '@backstage/config'; + +/** + * 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 + */ + +/** A serialized install step as it appears in JSON */ +export type SerializedStep = { + type: string; +} & unknown; + +export type InstallationRecipe = { + type?: 'frontend' | 'backend'; + steps: SerializedStep[]; +}; + +/** package.json data */ +export type PackageWithInstallRecipe = YarnInfoInspectData & { + version: string; + experimentalInstallationRecipe?: 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; +} 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[]; diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index d5027fff2c..3e179c75a3 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -60,5 +60,22 @@ }, "files": [ "dist" - ] + ], + "experimentalInstallationRecipe": { + "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" + ] + } + ] + } }