cli: refactor plugin installation steps into separate modules

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Himanshu Mishra <himanshu@orkohunter.net>
This commit is contained in:
Himanshu Mishra
2021-09-17 17:38:46 +02:00
parent c8215e1fe6
commit d9dde3129e
6 changed files with 295 additions and 140 deletions
+25 -116
View File
@@ -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<Step>();
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<string>();
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 <Route path="${step.path}" element={${step.element}} />$1</FlatRoutes>`,
);
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. <Route path="${step.path}" element={${step.element}} />`);
} 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();
};
@@ -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 <Route path="${path}" element={${element}} />$1</FlatRoutes>`,
);
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. <Route path="${path}" element={${element}} />`);
} else {
await fs.writeFile(appTsxPath, contentsWithImport);
}
}
}
export const appRoute = createStepDefinition<Data>({
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);
},
});
@@ -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<string>();
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<Data>({
type: 'dependencies',
deserialize() {
throw new Error('The dependency step may not be defined in JSON');
},
create(data: Data) {
return new DependenciesStep(data);
},
});
@@ -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';
@@ -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<Data>({
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);
},
});
+29 -24
View File
@@ -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<void>;
}
export interface StepDefinition<Options> {
/** 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<T>(
config: StepDefinition<T>,
): StepDefinition<T> {
return config;
}