cli: remove experimental install command

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-09-21 12:37:03 +02:00
parent 6129076bbd
commit b7ef1347b4
8 changed files with 2 additions and 495 deletions
+2
View File
@@ -10,3 +10,5 @@
- `test`: Use `backstage-cli repo test` or `backstage-cli package test` instead
- `versions:check`: Use `yarn dedupe` or `yarn-deduplicate` instead
- `clean`: Use `backstage-cli package clean` instead
In addition, the experimental `install` command has been removed since it has not received any updates since its introduction and we're expecting usage to be low. If you where relying on this command, please let us know by opening an issue towards the main Backstage repository.
-9
View File
@@ -379,15 +379,6 @@ export function registerCommands(program: Command) {
.command('info')
.description('Show helpful information for debugging and reporting bugs')
.action(lazy(() => import('./info').then(m => m.default)));
program
.command('install [plugin-id]', { hidden: true })
.option(
'--from <packageJsonFilePath>',
'Install from a local package.json containing the installation recipe',
)
.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
@@ -1,167 +0,0 @@
/*
* 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,
PeerPluginDependencies,
} from './types';
import { fetchPackageInfo } from '../../lib/versioning';
import { NotFoundError } from '../../lib/errors';
import * as stepDefinitionMap from './steps';
import { OptionValues } from 'commander';
import fs from 'fs-extra';
const stepDefinitions = Object.values(stepDefinitionMap);
async function fetchPluginPackage(
id: string,
): Promise<PackageWithInstallRecipe> {
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,
versionToInstall?: string,
) {
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: versionToInstall || `^${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();
}
}
}
async function installPluginAndPeerPlugins(pkg: PackageWithInstallRecipe) {
const pluginDeps: PeerPluginDependencies = new Map();
pluginDeps.set(pkg.name, { pkg });
await loadPeerPluginDeps(pkg, pluginDeps);
console.log(`Installing ${pkg.name} AND any peer plugin dependencies.`);
for (const [_pluginDepName, pluginDep] of pluginDeps.entries()) {
const { pkg: pluginDepPkg, versionToInstall } = pluginDep;
console.log(
`Installing plugin: ${pluginDepPkg.name}: ${
versionToInstall || pluginDepPkg.version
}`,
);
const steps = await PluginInstaller.resolveSteps(
pluginDepPkg,
versionToInstall,
);
const installer = new PluginInstaller(steps);
await installer.run();
}
}
async function loadPackageJson(
plugin: string,
): Promise<PackageWithInstallRecipe> {
if (plugin.endsWith('package.json')) {
// Install from local package if pluginId is a package.json file - needs to be absolute path
return await fs.readJson(plugin);
}
return await fetchPluginPackage(plugin);
}
async function loadPeerPluginDeps(
pkg: PackageWithInstallRecipe,
pluginMap: PeerPluginDependencies,
) {
for (const [pluginId, pluginVersion] of Object.entries(
pkg.experimentalInstallationRecipe?.peerPluginDependencies ?? {},
)) {
const depPkg = await loadPackageJson(pluginId);
if (!pluginMap.get(depPkg.name)) {
pluginMap.set(depPkg.name, {
pkg: depPkg,
versionToInstall: pluginVersion,
});
await loadPeerPluginDeps(depPkg, pluginMap);
}
}
}
export default async (pluginId?: string, cmd?: OptionValues) => {
const from = pluginId || cmd?.from;
// TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command?
if (!from) {
throw new Error(
'Missing both <plugin-id> or a package.json file path in the --from flag.',
);
}
const pkg = await loadPackageJson(from);
await installPluginAndPeerPlugins(pkg);
};
@@ -1,92 +0,0 @@
/*
* 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);
},
});
@@ -1,82 +0,0 @@
/*
* 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);
},
});
@@ -1,19 +0,0 @@
/*
* 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';
@@ -1,48 +0,0 @@
/*
* 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);
},
});
@@ -1,78 +0,0 @@
/*
* 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/types';
/**
* 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 Catalogs 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[];
peerPluginDependencies: { [pluginId: string]: string };
};
/** package.json data */
export type PackageWithInstallRecipe = YarnInfoInspectData & {
version: string;
experimentalInstallationRecipe?: InstallationRecipe;
};
export type PeerPluginDependencies = Map<
string,
{
pkg: PackageWithInstallRecipe;
versionToInstall?: string;
}
>;
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;
}