cli: remove experimental install command
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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 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[];
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user