Merge pull request #26811 from backstage/rugvip/cleanup

cli: remove deprecated and experimental commands
This commit is contained in:
Patrik Oldsberg
2024-09-23 16:59:23 +02:00
committed by GitHub
49 changed files with 57 additions and 3776 deletions
-125
View File
@@ -13,7 +13,6 @@ Options:
Commands:
new [options]
test
config:docs [options]
config:print [options]
config:check [options]
@@ -23,7 +22,6 @@ Commands:
migrate [command]
versions:bump [options]
versions:migrate [options]
clean
build-workspace [options] <workspace-dir> [packages...]
create-github-app <github-org>
info
@@ -40,15 +38,6 @@ Options:
-h, --help
```
### `backstage-cli clean`
```
Usage: backstage-cli clean [options]
Options:
-h, --help
```
### `backstage-cli config:check`
```
@@ -481,120 +470,6 @@ Options:
-h, --help
```
### `backstage-cli test`
```
Usage: backstage-cli [--config=<pathToConfigFile>] [TestPathPattern]
Options:
-h, --help
--version
--all
--automock
-b, --bail
--cache
--cacheDirectory
--changedFilesWithAncestor
--changedSince
--ci
--clearCache
--clearMocks
--collectCoverage
--collectCoverageFrom
--color
--colors
-c, --config
--coverage
--coverageDirectory
--coveragePathIgnorePatterns
--coverageProvider
--coverageReporters
--coverageThreshold
--debug
--detectLeaks
--detectOpenHandles
--env
--errorOnDeprecated
-e, --expand
--filter
--findRelatedTests
--forceExit
--globalSetup
--globalTeardown
--globals
--haste
--ignoreProjects
--init
--injectGlobals
--json
--lastCommit
--listTests
--logHeapUsage
--maxConcurrency
-w, --maxWorkers
--moduleDirectories
--moduleFileExtensions
--moduleNameMapper
--modulePathIgnorePatterns
--modulePaths
--noStackTrace
--notify
--notifyMode
-o, --onlyChanged
-f, --onlyFailures
--openHandlesTimeout
--outputFile
--passWithNoTests
--preset
--prettierPath
--projects
--randomize
--reporters
--resetMocks
--resetModules
--resolver
--restoreMocks
--rootDir
--roots
-i, --runInBand
--runTestsByPath
--runner
--seed
--selectProjects
--setupFiles
--setupFilesAfterEnv
--shard
--showConfig
--showSeed
--silent
--skipFilter
--snapshotSerializers
--testEnvironment
--testEnvironmentOptions
--testFailureExitCode
--testLocationInResults
--testMatch
-t, --testNamePattern
--testPathIgnorePatterns
--testPathPattern
--testRegex
--testResultsProcessor
--testRunner
--testSequencer
--testTimeout
--transform
--transformIgnorePatterns
--unmockedModulePathPatterns
-u, --updateSnapshot
--useStderr
--verbose
--watch
--watchAll
--watchPathIgnorePatterns
--watchman
--workerThreads
```
### `backstage-cli versions:bump`
```
@@ -1,40 +0,0 @@
/*
* Copyright 2020 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 path from 'path';
import { movePlugin } from './createPlugin';
import { createMockDirectory } from '@backstage/backend-test-utils';
const id = 'testPluginMock';
describe('createPlugin', () => {
const mockDir = createMockDirectory();
describe('movePlugin', () => {
it('should move the temporary plugin directory to its final place', async () => {
mockDir.setContent({
[id]: {},
});
const tempDir = mockDir.resolve(id);
const pluginDir = mockDir.resolve('test-temp/plugins', id);
await movePlugin(tempDir, pluginDir, id);
await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
expect(pluginDir).toMatch(path.join('', 'plugins', id));
});
});
});
@@ -1,343 +0,0 @@
/*
* Copyright 2020 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 { promisify } from 'util';
import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { join as joinPath, resolve as resolvePath } from 'path';
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import os from 'os';
import { OptionValues } from 'commander';
import { assertError } from '@backstage/errors';
import {
addCodeownersEntry,
getCodeownersFilePath,
parseOwnerIds,
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { Task, templatingTask } from '../../lib/tasks';
import { Lockfile } from '../../lib/versioning';
import { createPackageVersionProvider } from '../../lib/version';
const exec = promisify(execCb);
async function checkExists(destination: string) {
await Task.forItem('checking', destination, async () => {
if (await fs.pathExists(destination)) {
const existing = chalk.cyan(
destination.replace(`${paths.targetRoot}/`, ''),
);
throw new Error(
`A plugin with the same name already exists: ${existing}\nPlease try again with a different plugin ID`,
);
}
});
}
const sortObjectByKeys = (obj: { [name in string]: string }) => {
return Object.keys(obj)
.sort()
.reduce((result, key: string) => {
result[key] = obj[key];
return result;
}, {} as { [name in string]: string });
};
export const capitalize = (str: string): string =>
str.charAt(0).toUpperCase() + str.slice(1);
export const addExportStatement = async (
file: string,
exportStatement: string,
) => {
const newContents = fs
.readFileSync(file, 'utf8')
.split('\n')
.filter(Boolean) // get rid of empty lines
.concat([exportStatement])
.concat(['']) // newline at end of file
.join('\n');
await fs.writeFile(file, newContents, 'utf8');
};
export async function addPluginDependencyToApp(
rootDir: string,
pluginPackage: string,
versionStr: string,
) {
const packageFilePath = 'packages/app/package.json';
const packageFile = resolvePath(rootDir, packageFilePath);
await Task.forItem('processing', packageFilePath, async () => {
const packageFileContent = await fs.readFile(packageFile, 'utf-8');
const packageFileJson = JSON.parse(packageFileContent);
const dependencies = packageFileJson.dependencies;
if (dependencies[pluginPackage]) {
throw new Error(
`Plugin ${pluginPackage} already exists in ${packageFile}`,
);
}
dependencies[pluginPackage] = `^${versionStr}`;
packageFileJson.dependencies = sortObjectByKeys(dependencies);
const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`;
await fs.writeFile(packageFile, newContents, 'utf-8').catch(error => {
throw new Error(
`Failed to add plugin as dependency to app: ${packageFile}: ${error.message}`,
);
});
});
}
export async function addPluginExtensionToApp(
pluginId: string,
extensionName: string,
pluginPackage: string,
) {
const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx');
if (!(await fs.pathExists(pluginsFilePath))) {
return;
}
await Task.forItem('processing', pluginsFilePath, async () => {
const content = await fs.readFile(pluginsFilePath, 'utf8');
const revLines = content.split('\n').reverse();
const lastImportIndex = revLines.findIndex(line =>
line.match(/ from ("|').*("|')/),
);
const lastRouteIndex = revLines.findIndex(line =>
line.match(/<\/FlatRoutes/),
);
if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
revLines.splice(
lastImportIndex,
0,
`import { ${extensionName} } from '${pluginPackage}';`,
);
const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
revLines.splice(
lastRouteIndex + 1,
0,
`${indentation}<Route path="/${pluginId}" element={<${extensionName} />}/>`,
);
const newContent = revLines.reverse().join('\n');
await fs.writeFile(pluginsFilePath, newContent, 'utf8');
}
});
}
async function cleanUp(tempDir: string) {
await Task.forItem('remove', 'temporary directory', async () => {
await fs.remove(tempDir);
});
}
async function buildPlugin(pluginFolder: string) {
const commands = [
'yarn install',
'yarn lint --fix',
'yarn tsc',
'yarn build',
];
for (const command of commands) {
try {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
await exec(command);
}).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
`Warning: Could not execute command ${chalk.cyan(command)}`,
);
});
} catch (error) {
assertError(error);
Task.error(error.message);
break;
}
}
}
export async function movePlugin(
tempDir: string,
destination: string,
id: string,
) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
`Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`,
);
});
});
}
export default async (opts: OptionValues) => {
const codeownersPath = await getCodeownersFilePath(paths.targetRoot);
const questions: Question[] = [
{
type: 'input',
name: 'id',
message: chalk.blue('Enter an ID for the plugin [required]'),
validate: (value: any) => {
if (!value) {
return chalk.red('Please enter an ID for the plugin');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
'Plugin IDs must be lowercase and contain only letters, digits, and dashes.',
);
}
return true;
},
},
];
if (codeownersPath) {
questions.push({
type: 'input',
name: 'owner',
message: chalk.blue(
'Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional]',
),
validate: (value: any) => {
if (!value) {
return true;
}
const ownerIds = parseOwnerIds(value);
if (!ownerIds) {
return chalk.red(
'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses of users (e.g. user@example.com).',
);
}
return true;
},
});
}
const answers: Answers = await inquirer.prompt(questions);
const pluginId =
opts.backend && !answers.id.endsWith('-backend')
? `${answers.id}-backend`
: answers.id;
const name = opts.scope
? `@${opts.scope.replace(/^@/, '')}/plugin-${pluginId}`
: `plugin-${pluginId}`;
const pluginVar = `${camelCase(answers.id)}Plugin`;
const extensionName = `${upperFirst(camelCase(answers.id))}Page`;
const npmRegistry = opts.npmRegistry && opts.scope ? opts.npmRegistry : '';
const privatePackage = opts.private === false ? false : true;
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
const appPackage = paths.resolveTargetRoot('packages/app');
const templateDir = paths.resolveOwn(
opts.backend
? 'templates/default-backend-plugin'
: 'templates/default-plugin',
);
const pluginDir = isMonoRepo
? paths.resolveTargetRoot('plugins', pluginId)
: paths.resolveTargetRoot(pluginId);
const { version: pluginVersion } = isMonoRepo
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
: { version: '0.1.0' };
const license = opts.license ?? 'Apache-2.0';
let lockfile: Lockfile | undefined;
try {
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
} catch (error) {
console.warn(`No yarn.lock available, ${error}`);
}
Task.log();
Task.log('Creating the plugin...');
Task.section('Checking if the plugin ID is available');
await checkExists(pluginDir);
Task.section('Creating a temporary plugin directory');
const tempDir = await fs.mkdtemp(
joinPath(os.tmpdir(), `backstage-plugin-${pluginId}`),
);
try {
Task.section('Preparing files');
await templatingTask(
templateDir,
tempDir,
{
...answers,
pluginVar,
pluginVersion,
extensionName,
name,
privatePackage,
npmRegistry,
license,
},
createPackageVersionProvider(lockfile),
isMonoRepo,
);
Task.section('Moving to final location');
await movePlugin(tempDir, pluginDir, pluginId);
Task.section('Building the plugin');
await buildPlugin(pluginDir);
if ((await fs.pathExists(appPackage)) && !opts.backend) {
Task.section('Adding plugin as dependency in app');
await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
Task.section('Import plugin in app');
await addPluginExtensionToApp(pluginId, extensionName, name);
}
if (answers.owner) {
await addCodeownersEntry(`/plugins/${pluginId}`, answers.owner);
}
Task.log();
Task.log(`🥇 Successfully created ${chalk.cyan(`${name}`)}`);
Task.log();
Task.exit();
} catch (error) {
assertError(error);
Task.error(error.message);
Task.log('It seems that something went wrong when creating the plugin 🤔');
Task.log('We are going to clean up, and then you can try again.');
Task.section('Cleanup');
await cleanUp(tempDir);
Task.error('🔥 Failed to create plugin!');
Task.exit(1);
}
};
+41 -84
View File
@@ -25,13 +25,6 @@ const configOption = [
Array<string>(),
] as const;
export function registerOnboardCommand(program: Command) {
program
.command('onboard', { hidden: true })
.description('Get help setting up your Backstage App.')
.action(lazy(() => import('./onboard').then(m => m.command)));
}
export function registerRepoCommand(program: Command) {
const command = program
.command('repo [command]')
@@ -265,63 +258,6 @@ export function registerCommands(program: Command) {
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./new/new').then(m => m.default)));
program
.command('create', { hidden: true })
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app [DEPRECATED]',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./new/new').then(m => m.default)));
program
.command('create-plugin', { hidden: true })
.option(
'--backend',
'Create plugin with the backend dependencies as default',
)
.description('Creates a new plugin in the current repository [DEPRECATED]')
.option('--scope <scope>', 'npm scope')
.option('--npm-registry <URL>', 'npm registry URL')
.option('--no-private', 'Public npm package')
.action(
lazy(() => import('./create-plugin/createPlugin').then(m => m.default)),
);
program
.command('plugin:diff', { hidden: true })
.option('--check', 'Fail if changes are required')
.option('--yes', 'Apply all changes')
.description(
'Diff an existing plugin with the creation template [DEPRECATED]',
)
.action(lazy(() => import('./plugin/diff').then(m => m.default)));
// TODO(Rugvip): Deprecate in favor of package variant
program
.command('test')
.allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args
.helpOption(', --backstage-cli-help') // Let Jest handle help
.description(
'Run tests, forwarding args to Jest, defaulting to watch mode [DEPRECATED]',
)
.action(lazy(() => import('./test').then(m => m.default)));
program
.command('config:docs')
.option(
@@ -385,7 +321,6 @@ export function registerCommands(program: Command) {
registerRepoCommand(program);
registerScriptCommand(program);
registerMigrateCommand(program);
registerOnboardCommand(program);
program
.command('versions:bump')
@@ -403,12 +338,6 @@ export function registerCommands(program: Command) {
.description('Bump Backstage packages to the latest versions')
.action(lazy(() => import('./versions/bump').then(m => m.default)));
program
.command('versions:check', { hidden: true })
.option('--fix', 'Fix any auto-fixable versioning problems')
.description('Check Backstage package versioning')
.action(lazy(() => import('./versions/lint').then(m => m.default)));
program
.command('versions:migrate')
.option(
@@ -424,12 +353,6 @@ export function registerCommands(program: Command) {
)
.action(lazy(() => import('./versions/migrate').then(m => m.default)));
// TODO(Rugvip): Deprecate in favor of package variant
program
.command('clean')
.description('Delete cache directories [DEPRECATED]')
.action(lazy(() => import('./clean/clean').then(m => m.default)));
program
.command('build-workspace <workspace-dir> [packages...]')
.option(
@@ -449,14 +372,48 @@ export function registerCommands(program: Command) {
.description('Show helpful information for debugging and reporting bugs')
.action(lazy(() => import('./info').then(m => m.default)));
// Notifications for removed commands
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)));
.command('create')
.allowUnknownOption(true)
.action(removed("use 'backstage-cli new' instead"));
program
.command('create-plugin')
.allowUnknownOption(true)
.action(removed("use 'backstage-cli new' instead"));
program
.command('plugin:diff')
.allowUnknownOption(true)
.action(removed("use 'backstage-cli fix' instead"));
program
.command('test')
.allowUnknownOption(true)
.action(
removed(
"use 'backstage-cli repo test' or 'backstage-cli package test' instead",
),
);
program
.command('clean')
.allowUnknownOption(true)
.action(removed("use 'backstage-cli package clean' instead"));
program
.command('versions:check')
.allowUnknownOption(true)
.action(removed("use 'yarn dedupe' or 'yarn-deduplicate' instead"));
program.command('install').allowUnknownOption(true).action(removed());
program.command('onboard').allowUnknownOption(true).action(removed());
}
function removed(message?: string) {
return () => {
console.error(
message
? `This command has been removed, ${message}`
: 'This command has been removed',
);
process.exit(1);
};
}
// 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;
}
@@ -1,22 +0,0 @@
# onboard
## Local dev
# Create a new app
```bash
cd ..
npx @backstage/create-app
cd new-backstage-app
# Run the modded cli in the new app
../RELATIVE_PATH_TO_PROJECT/backstage/packages/cli/bin/backstage-cli onboard
? Do you want to set up Authentication for this project? (Y/n)
...
# Try the app with the new changes
yarn dev
```
@@ -1,63 +0,0 @@
/*
* Copyright 2023 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 inquirer from 'inquirer';
import { Task } from '../../../lib/tasks';
import { github, Answers as GitHubAnswers } from './providers/github';
import { gitlab, Answers as GitLabAnswers } from './providers/gitlab';
export { type GitHubAnswers, type GitLabAnswers };
export async function auth(): Promise<{
provider: string;
answers: GitHubAnswers | GitLabAnswers;
}> {
const answers = await inquirer.prompt<{
provider: string;
}>([
{
type: 'list',
name: 'provider',
message: 'Please select an authentication provider:',
choices: ['GitHub', 'GitLab'],
},
]);
const { provider } = answers;
let providerAnswers;
switch (provider) {
case 'GitHub': {
providerAnswers = await github();
break;
}
case 'GitLab': {
providerAnswers = await gitlab();
break;
}
default:
throw new Error(`Provider ${provider} not implemented yet.`);
}
Task.log();
Task.log(`Done setting up ${provider} Authentication!`);
Task.log();
return {
provider,
answers: providerAnswers,
};
}
@@ -1,43 +0,0 @@
/*
* Copyright 2023 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 * as fs from 'fs-extra';
import * as path from 'path';
import * as differ from 'diff';
import { PATCH_FOLDER } from '../files';
import { findPaths } from '@backstage/cli-common';
/* eslint-disable-next-line no-restricted-syntax */
const { targetRoot } = findPaths(__dirname);
export const patch = async (patchFile: string) => {
const patchContent = await fs.readFile(
path.join(PATCH_FOLDER, patchFile),
'utf8',
);
const targetName = patchContent.split('\n')[0].replace('--- a', '');
const targetFile = path.join(targetRoot, targetName);
const oldContent = await fs.readFile(targetFile, 'utf8');
const newContent = differ.applyPatch(oldContent, patchContent);
if (!newContent) {
throw new Error(
`Patch ${patchFile} was not applied correctly.
Did you change ${targetName} manually before running this command?`,
);
}
return await fs.writeFile(targetFile, newContent, 'utf8');
};
@@ -1,25 +0,0 @@
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -30 +30,5 @@ import { Root } from './components/Root';
-import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
+import {
+ AlertDisplay,
+ OAuthRequestDialog,
+ SignInPage,
+} from '@backstage/core-components';
@@ -35,0 +40 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/
+import { githubAuthApiRef } from '@backstage/core-plugin-api';
@@ -38,0 +44,14 @@ const app = createApp({
+ components: {
+ SignInPage: props => (
+ <SignInPage
+ {...props}
+ provider={{
+ id: 'github-auth-provider',
+ title: 'GitHub',
+ message: 'Sign in using GitHub',
+ apiRef: githubAuthApiRef,
+ }}
+ />
+ ),
+ },
@@ -1,25 +0,0 @@
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -30 +30,5 @@ import { Root } from './components/Root';
-import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
+import {
+ AlertDisplay,
+ OAuthRequestDialog,
+ SignInPage,
+} from '@backstage/core-components';
@@ -35,0 +40 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/
+import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
@@ -38,0 +44,14 @@ const app = createApp({
+ components: {
+ SignInPage: props => (
+ <SignInPage
+ {...props}
+ provider={{
+ id: 'gitlab-auth-provider',
+ title: 'GitLab',
+ message: 'Sign in using GitLab',
+ apiRef: gitlabAuthApiRef,
+ }}
+ />
+ ),
+ },
@@ -1,5 +0,0 @@
--- a/packages/backend/src/plugins/auth.ts
+++ b/packages/backend/src/plugins/auth.ts
@@ -38 +38 @@ export default async function createPlugin(
- github: providers.github.create({
+ gitlab: providers.gitlab.create({
@@ -1,147 +0,0 @@
/*
* Copyright 2023 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 { OAuthApp } from '@octokit/oauth-app';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import { Task } from '../../../../lib/tasks';
import { updateConfigFile } from '../../config';
import { APP_CONFIG_FILE, PATCH_FOLDER } from '../../files';
import { patch } from '../patch';
export type Answers = {
clientSecret: string;
clientId: string;
hasEnterprise: boolean;
enterpriseInstanceUrl?: string;
};
const validateCredentials = async (clientId: string, clientSecret: string) => {
try {
const app = new OAuthApp({
clientId,
clientSecret,
});
await app.createToken({
code: '%NOT-VALID-CODE%',
});
} catch (error) {
// @octokit/request returns a error.response object when a request is rejected.
// We can check it to see what kind of error we received.
// If error.response is successful we can double-check that the error itself was due to the bad code.
// If that's the case then we can assume that the client id and secret exists as we otherwise would
// have gotten a 400/404.
if (
error.response.status !== 200 &&
error.response.data.error !== 'bad_verification_code'
) {
throw new Error(`Validating GitHub Credentials failed.`);
}
}
};
const getConfig = (answers: Answers) => {
const { clientId, clientSecret, hasEnterprise, enterpriseInstanceUrl } =
answers;
return {
auth: {
providers: {
github: {
development: {
clientId,
clientSecret,
...(hasEnterprise && {
enterpriseInstanceUrl,
}),
},
},
},
},
};
};
export const github = async (): Promise<Answers> => {
Task.log(`
To add GitHub authentication, you must create an OAuth App from the GitHub developer settings: ${chalk.blue(
'https://github.com/settings/developers',
)}
The Homepage URL should point to Backstage's frontend, while the Authorization callback URL will point to the auth backend.
Settings for local development:
${chalk.cyan(`
Homepage URL: http://localhost:3000
Authorization callback URL: http://localhost:7007/api/auth/github/handler/frame`)}
You can find the full documentation page here: ${chalk.blue(
'https://backstage.io/docs/auth/github/provider',
)}
`);
const answers = await inquirer.prompt<Answers>([
{
type: 'input',
name: 'clientId',
message: 'What is your Client Id?',
validate: (input: string) => (input.length ? true : false),
},
{
type: 'input',
name: 'clientSecret',
message: 'What is your Client Secret?',
validate: (input: string) => (input.length ? true : false),
},
{
type: 'confirm',
name: 'hasEnterprise',
message: 'Are you using GitHub Enterprise?',
},
{
type: 'input',
name: 'enterpriseInstanceUrl',
message: 'What is your URL for GitHub Enterprise?',
when: ({ hasEnterprise }) => hasEnterprise,
validate: (input: string) => Boolean(new URL(input)),
},
]);
const { clientId, clientSecret } = answers;
const config = getConfig(answers);
Task.log('Setting up GitHub Authentication for you...');
await Task.forItem(
'Validating',
'credentials',
async () => await validateCredentials(clientId, clientSecret),
);
await Task.forItem(
'Updating',
APP_CONFIG_FILE,
async () => await updateConfigFile(APP_CONFIG_FILE, config),
);
const patches = await fs.readdir(PATCH_FOLDER);
for (const patchFile of patches.filter(p => p.includes('github'))) {
await Task.forItem('Patching', patchFile, async () => {
await patch(patchFile);
});
}
return answers;
};
@@ -1,115 +0,0 @@
/*
* Copyright 2023 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 chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import { Task } from '../../../../lib/tasks';
import { updateConfigFile } from '../../config';
import { APP_CONFIG_FILE, PATCH_FOLDER } from '../../files';
import { patch } from '../patch';
const getConfig = (answers: Answers) => {
const { clientId, clientSecret, hasAudience, audience } = answers;
return {
auth: {
providers: {
gitlab: {
development: {
clientId,
clientSecret,
...(hasAudience && {
audience,
}),
},
},
},
},
};
};
export type Answers = {
clientSecret: string;
clientId: string;
hasAudience: boolean;
audience?: string;
};
export const gitlab = async (): Promise<Answers> => {
Task.log(`
To add GitLab authentication, you must create an Application from the GitLab Settings: ${chalk.blue(
'https://gitlab.com/-/profile/applications',
)}
The Redirect URI should point to your Backstage backend auth handler.
Settings for local development:
${chalk.cyan(`
Name: Backstage (or your custom app name)
Redirect URI: http://localhost:7007/api/auth/gitlab/handler/frame
Scopes: read_api and read_user`)}
You can find the full documentation page here: ${chalk.blue(
'https://backstage.io/docs/auth/gitlab/provider',
)}
`);
const answers = await inquirer.prompt<Answers>([
{
type: 'input',
name: 'clientId',
message: 'What is your Application Id?',
validate: (input: string) => (input.length ? true : false),
},
{
type: 'input',
name: 'clientSecret',
message: 'What is your Application Secret?',
validate: (input: string) => (input.length ? true : false),
},
{
type: 'confirm',
name: 'hasAudience',
message: 'Do you have a self-hosted instance of GitLab?',
},
{
type: 'input',
name: 'audience',
message: 'What is the URL for your GitLab instance?',
when: ({ hasAudience }) => hasAudience,
validate: (input: string) => Boolean(new URL(input)),
},
]);
const config = getConfig(answers);
Task.log('Setting up GitLab Authentication for you...');
await Task.forItem(
'Updating',
APP_CONFIG_FILE,
async () => await updateConfigFile(APP_CONFIG_FILE, config),
);
const patches = await fs.readdir(PATCH_FOLDER);
for (const patchFile of patches.filter(p => p.includes('gitlab'))) {
await Task.forItem('Patching', patchFile, async () => {
await patch(patchFile);
});
}
return answers;
};
@@ -1,83 +0,0 @@
/*
* Copyright 2023 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 chalk from 'chalk';
import inquirer from 'inquirer';
import { Task } from '../../lib/tasks';
import { auth } from './auth';
import { integrations } from './integrations';
import { discover } from './discovery';
export async function command(): Promise<void> {
const answers = await inquirer.prompt<{
shouldSetupAuth: boolean;
shouldSetupScaffolder: boolean;
shouldDiscoverEntities: boolean;
}>([
{
type: 'confirm',
name: 'shouldSetupAuth',
message: 'Do you want to set up Authentication for this project?',
default: true,
},
{
type: 'confirm',
name: 'shouldSetupScaffolder',
message: 'Do you want to use Software Templates in this project?',
default: true,
},
{
type: 'confirm',
name: 'shouldDiscoverEntities',
message:
'Do you want to discover entities and add them to the Software Catalog?',
default: true,
},
]);
const { shouldSetupAuth, shouldSetupScaffolder, shouldDiscoverEntities } =
answers;
if (!shouldSetupAuth && !shouldSetupScaffolder && !shouldDiscoverEntities) {
Task.log(
chalk.yellow(
'If you change your mind, feel free to re-run this command.',
),
);
return;
}
let providerInfo;
if (shouldSetupAuth) {
providerInfo = await auth();
}
if (shouldSetupScaffolder) {
await integrations(providerInfo);
}
if (shouldDiscoverEntities) {
await discover(providerInfo);
}
Task.log();
Task.log(
`You can now start your app with ${chalk.inverse(
chalk.italic('yarn dev'),
)}`,
);
Task.log();
}
@@ -1,45 +0,0 @@
/*
* Copyright 2023 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 * as fs from 'fs-extra';
import yaml from 'yaml';
const readYaml = async (file: string) => {
return yaml.parse(await fs.readFile(file, 'utf8'));
};
export const updateConfigFile = async <T>(file: string, config: T) => {
const staticContent =
'# Backstage override configuration for your local development environment \n';
const content = fs.existsSync(file)
? yaml.stringify(
{ ...(await readYaml(file)), ...config },
{
indent: 2,
},
)
: staticContent.concat(
yaml.stringify(
{ ...config },
{
indent: 2,
},
),
);
return await fs.writeFile(file, content, 'utf8');
};
@@ -1,71 +0,0 @@
/*
* Copyright 2023 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 chalk from 'chalk';
import { Entity } from '@backstage/catalog-model';
import { Analyzer } from './analyzers/types';
import { Provider } from './providers/types';
import { DefaultAnalysisOutputs } from './analyzers/DefaultAnalysisOutputs';
import { Task } from '../../../lib/tasks';
export class Discovery {
readonly #providers: Provider[] = [];
readonly #analyzers: Analyzer[] = [];
addProvider(provider: Provider) {
this.#providers.push(provider);
}
addAnalyzer(analyzer: Analyzer) {
this.#analyzers.push(analyzer);
}
async run(url: string): Promise<{ entities: Entity[] }> {
Task.log(`Running discovery for ${chalk.cyan(url)}`);
const result: Entity[] = [];
for (const provider of this.#providers) {
const repositories = await provider.discover(url);
if (repositories && repositories.length) {
Task.log(
`Discovered ${chalk.cyan(
repositories.length,
)} repositories for ${chalk.cyan(provider.name())}`,
);
for (const repository of repositories) {
await Task.forItem('Analyzing', repository.name, async () => {
const output = new DefaultAnalysisOutputs();
for (const analyzer of this.#analyzers) {
await analyzer.analyzeRepository({ repository, output });
}
output
.list()
.filter(entry => entry.type === 'entity')
.forEach(({ entity }) => result.push(entity));
});
}
Task.log(`Produced ${chalk.cyan(result.length || 'no')} entities`);
}
}
return {
entities: result,
};
}
}
@@ -1,56 +0,0 @@
/*
* Copyright 2023 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 { ComponentEntity } from '@backstage/catalog-model';
import { AnalysisOutputs, Analyzer } from './types';
import { Repository } from '../providers/types';
/**
* Naive analyzer that produces a single entity that represents the repository
* as a whole.
*/
export class BasicRepositoryAnalyzer implements Analyzer {
name(): string {
return BasicRepositoryAnalyzer.name;
}
async analyzeRepository(options: {
repository: Repository;
output: AnalysisOutputs;
}): Promise<void> {
const entity: ComponentEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: options.repository.name,
...(options.repository.description
? { description: options.repository.description }
: {}),
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'user:guest',
},
};
options.output.produce({
type: 'entity',
path: '/',
entity: entity,
});
}
}
@@ -1,29 +0,0 @@
/*
* Copyright 2023 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 { AnalysisOutput, AnalysisOutputs } from './types';
export class DefaultAnalysisOutputs implements AnalysisOutputs {
readonly #outputs = new Map<string, AnalysisOutput>();
produce(output: AnalysisOutput) {
this.#outputs.set(output.entity.metadata.name, output);
}
list() {
return Array.from(this.#outputs).map(([_, output]) => output);
}
}
@@ -1,128 +0,0 @@
/*
* Copyright 2023 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 {
ANNOTATION_SOURCE_LOCATION,
ComponentEntity,
} from '@backstage/catalog-model';
import z from 'zod';
import { AnalysisOutputs, Analyzer } from './types';
import { Repository, RepositoryFile } from '../providers/types';
export class PackageJsonAnalyzer implements Analyzer {
name(): string {
return PackageJsonAnalyzer.name;
}
async analyzeRepository(options: {
repository: Repository;
output: AnalysisOutputs;
}): Promise<void> {
const packageJson = await options.repository.file('package.json');
if (!packageJson) {
return;
}
const content = await readPackageJson(packageJson);
if (!content) {
return;
}
const name = sanitizeName(content?.name) ?? options.repository.name;
const entity: ComponentEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name,
...(options.repository.description
? { description: options.repository.description }
: {}),
tags: ['javascript'],
annotations: {
[ANNOTATION_SOURCE_LOCATION]: `url:${options.repository.url}`,
},
},
spec: {
type: 'website',
lifecycle: 'production',
owner: 'user:guest',
},
};
const decorate = options.output
.list()
.find(entry => entry.entity.metadata.name === name);
if (decorate) {
decorate.entity.spec = {
...decorate.entity.spec,
type: 'website',
};
decorate.entity.metadata.tags = [
...(decorate.entity.metadata.tags ?? []),
'javascript',
];
decorate.entity.metadata.annotations = {
...decorate.entity.metadata.annotations,
[ANNOTATION_SOURCE_LOCATION]: `url:${options.repository.url}`,
};
return;
}
options.output.produce({
type: 'entity',
path: '/',
entity,
});
}
}
const packageSchema = z.object({
name: z.string().optional(),
});
/**
* Makes sure that a name retrieved from a package.json file
* is reasonable and conforms to the catalog naming format.
*
* Read more about the naming format here:
* ADR002: Default Software Catalog File Format
* https://backstage.io/docs/architecture-decisions/adrs-adr002/
*/
function sanitizeName(name?: string) {
return name && name !== 'root'
? name.replace(/[^a-z0-9A-Z]/g, '_').substring(0, 62)
: undefined;
}
async function readPackageJson(
file: RepositoryFile,
): Promise<z.infer<typeof packageSchema> | undefined> {
try {
const text = await file.text();
const result = packageSchema.safeParse(JSON.parse(text));
if (!result.success) {
return undefined;
}
return { name: result.data.name };
} catch (e) {
return undefined;
}
}
@@ -1,37 +0,0 @@
/*
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
import { Repository } from '../providers/types';
export type AnalysisOutput = {
type: 'entity';
path: string;
entity: Entity;
};
export interface AnalysisOutputs {
produce(output: AnalysisOutput): void;
list(): AnalysisOutput[];
}
export interface Analyzer {
name(): string;
analyzeRepository(options: {
repository: Repository;
output: AnalysisOutputs;
}): Promise<void>;
}
@@ -1,141 +0,0 @@
/*
* Copyright 2023 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 yaml from 'yaml';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { loadCliConfig } from '../../../lib/config';
import { updateConfigFile } from '../config';
import { APP_CONFIG_FILE, DISCOVERED_ENTITIES_FILE } from '../files';
import { Discovery } from './Discovery';
import { BasicRepositoryAnalyzer } from './analyzers/BasicRepositoryAnalyzer';
import { PackageJsonAnalyzer } from './analyzers/PackageJsonAnalyzer';
import { GithubDiscoveryProvider } from './providers/github/GithubDiscoveryProvider';
import { GitlabDiscoveryProvider } from './providers/gitlab/GitlabDiscoveryProvider';
import { GitHubAnswers, GitLabAnswers } from '../auth';
import { Task } from '../../../lib/tasks';
export async function discover(providerInfo?: {
provider: string;
answers: GitHubAnswers | GitLabAnswers;
}) {
Task.log(`
Would you like to scan for - and create - Software Catalog entities?
You will need to select which SCM (Source Code Management) provider you are using,
and then which repository or organization you want to scan.
This will generate a new file in the root of your app containing discovered entities,
which will be included in the Software Catalog when you start up Backstage next time.
Note that this command requires an access token, which can be either added through the integration config or
provided as an environment variable.
`);
const answers = await inquirer.prompt<{
shouldContinue: boolean;
provider: string;
url: string;
}>([
{
type: 'confirm',
name: 'shouldContinue',
message: 'Do you want to continue?',
},
{
type: 'list',
name: 'provider',
message: 'Please select which SCM provider you want to use:',
choices: ['GitHub', 'GitLab'],
default: providerInfo?.provider,
when: ({ shouldContinue }) => shouldContinue,
},
{
type: 'input',
name: 'url',
message: `Which repository do you want to scan?`,
when: ({ shouldContinue }) => shouldContinue,
filter: (input, { provider }) => {
if (provider === 'GitLab') {
return `https://gitlab.com/${input}`;
}
if (provider === 'GitHub') {
return `https://github.com/${input}`;
}
return false;
},
},
]);
if (!answers.shouldContinue) {
Task.log(
chalk.yellow(
'If you change your mind, feel free to re-run this command.',
),
);
return;
}
const { fullConfig: config } = await loadCliConfig({ args: [] });
const discovery = new Discovery();
if (answers.provider === 'GitHub') {
discovery.addProvider(GithubDiscoveryProvider.fromConfig(config));
}
if (answers.provider === 'GitLab') {
discovery.addProvider(GitlabDiscoveryProvider.fromConfig(config));
}
discovery.addAnalyzer(new BasicRepositoryAnalyzer());
discovery.addAnalyzer(new PackageJsonAnalyzer());
const { entities } = await discovery.run(answers.url);
if (!entities.length) {
Task.log(
chalk.yellow(`
We could not find enough information to be able to generate any Software Catalog entities for you.
Perhaps you can try again with a different repository?`),
);
return;
}
await Task.forItem('Creating', DISCOVERED_ENTITIES_FILE, async () => {
const payload: string[] = [];
for (const entity of entities) {
payload.push('---\n', yaml.stringify(entity));
}
await fs.writeFile(DISCOVERED_ENTITIES_FILE, payload.join(''));
});
await Task.forItem(
'Updating',
APP_CONFIG_FILE,
async () =>
await updateConfigFile(APP_CONFIG_FILE, {
catalog: {
locations: [
{
type: 'file',
target: DISCOVERED_ENTITIES_FILE,
},
],
},
}),
);
}
@@ -1,163 +0,0 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { graphql } from '@octokit/graphql';
import {
Repository as GraphqlRepository,
Query as GraphqlQuery,
} from '@octokit/graphql-schema';
import parseGitUrl from 'git-url-parse';
import { Provider, Repository } from '../types';
import { GithubRepository } from './GithubRepository';
export class GithubDiscoveryProvider implements Provider {
readonly #envToken: string | undefined;
readonly #scmIntegrations: ScmIntegrations;
readonly #credentialsProvider: GithubCredentialsProvider;
static fromConfig(config: Config): GithubDiscoveryProvider {
const envToken = process.env.GITHUB_TOKEN || undefined;
const scmIntegrations = ScmIntegrations.fromConfig(config);
const credentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(scmIntegrations);
return new GithubDiscoveryProvider(
envToken,
scmIntegrations,
credentialsProvider,
);
}
private constructor(
envToken: string | undefined,
integrations: ScmIntegrations,
credentialsProvider: GithubCredentialsProvider,
) {
this.#envToken = envToken;
this.#scmIntegrations = integrations;
this.#credentialsProvider = credentialsProvider;
}
name(): string {
return 'GitHub';
}
async discover(url: string): Promise<Repository[] | false> {
if (!url.startsWith('https://github.com/')) {
return false;
}
const scmIntegration = this.#scmIntegrations.github.byUrl(url);
if (!scmIntegration) {
throw new Error(`No GitHub integration found for ${url}`);
}
const parsed = parseGitUrl(url);
const { name, organization } = parsed;
const org = organization || name; // depends on if it's a repo url or an org url...
const client = graphql.defaults({
baseUrl: scmIntegration.config.apiBaseUrl,
headers: await this.#getRequestHeaders(url),
});
const { repositories } = await this.#getOrganizationRepositories(
client,
org,
);
return repositories
.filter(repo => repo.url.startsWith(url))
.map(repo => new GithubRepository(client, repo, org));
}
async #getRequestHeaders(url: string): Promise<Record<string, string>> {
const credentials = await this.#credentialsProvider.getCredentials({
url,
});
if (credentials.headers) {
return credentials.headers;
} else if (credentials.token) {
return { authorization: `token ${credentials.token}` };
}
if (this.#envToken) {
return { authorization: `token ${this.#envToken}` };
}
throw new Error(
'No token available for GitHub, please configure your integrations or set a GITHUB_TOKEN env variable',
);
}
async #getOrganizationRepositories(client: typeof graphql, org: string) {
const query = `query repositories($org: String!, $cursor: String) {
repositoryOwner(login: $org) {
login
repositories(first: 50, after: $cursor) {
nodes {
name
url
description
isArchived
isFork
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`;
const result: GraphqlRepository[] = [];
let cursor: string | undefined | null = undefined;
let hasNextPage = true;
while (hasNextPage) {
const response: GraphqlQuery = await client(query, {
org,
cursor,
});
const { repositories: connection } = response.repositoryOwner ?? {};
if (!connection) {
throw new Error(`Found no repositories for ${org}`);
}
for (const repository of connection.nodes ?? []) {
if (repository && !repository.isArchived && !repository.isFork) {
result.push(repository);
}
}
cursor = connection.pageInfo.endCursor;
hasNextPage = connection.pageInfo.hasNextPage;
}
return {
repositories: result,
};
}
}
@@ -1,35 +0,0 @@
/*
* Copyright 2023 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 { RepositoryFile } from '../types';
export class GithubFile implements RepositoryFile {
readonly #path: string;
readonly #content: string;
constructor(path: string, content: string) {
this.#path = path;
this.#content = content;
}
get path(): string {
return this.#path;
}
async text(): Promise<string> {
return this.#content;
}
}
@@ -1,84 +0,0 @@
/*
* Copyright 2023 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 { graphql } from '@octokit/graphql';
import {
Repository as GraphqlRepository,
Blob as GraphqlBlob,
} from '@octokit/graphql-schema';
import { Repository, RepositoryFile } from '../types';
import { GithubFile } from './GithubFile';
export class GithubRepository implements Repository {
readonly #client: typeof graphql;
readonly #repo: GraphqlRepository;
readonly #org: string;
constructor(client: typeof graphql, repo: GraphqlRepository, org: string) {
this.#client = client;
this.#repo = repo;
this.#org = org;
}
get url(): string {
return this.#repo.url;
}
get name(): string {
return this.#repo.name;
}
get owner(): string {
return this.#org;
}
get description(): string | undefined {
return this.#repo.description ?? undefined;
}
async file(filename: string): Promise<RepositoryFile | undefined> {
const content = await this.#getFileContent(filename);
if (!content || content.isBinary || !content.text) {
return undefined;
}
return new GithubFile(filename, content.text ?? '');
}
async #getFileContent(filename: string) {
const query = `query RepoFiles($owner: String!, $name: String!, $expr: String!) {
repository(owner: $owner, name: $name) {
object(expression: $expr) {
...on Blob {
text
isBinary
}
}
}
}`;
const response = await this.#client<{ repository: GraphqlRepository }>(
query,
{
name: this.#repo.name,
owner: this.#org,
expr: `HEAD:${filename}`,
},
);
return response.repository.object as GraphqlBlob;
}
}
@@ -1,106 +0,0 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import {
DefaultGitlabCredentialsProvider,
GitlabCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import fetch from 'node-fetch';
import { Provider } from '../types';
import { GitlabProject, ProjectResponse } from './GitlabProject';
export class GitlabDiscoveryProvider implements Provider {
readonly #envToken: string | undefined;
readonly #scmIntegrations: ScmIntegrations;
readonly #credentialsProvider: GitlabCredentialsProvider;
static fromConfig(config: Config): GitlabDiscoveryProvider {
const envToken = process.env.GITLAB_TOKEN || undefined;
const scmIntegrations = ScmIntegrations.fromConfig(config);
const credentialsProvider =
DefaultGitlabCredentialsProvider.fromIntegrations(scmIntegrations);
return new GitlabDiscoveryProvider(
envToken,
scmIntegrations,
credentialsProvider,
);
}
private constructor(
envToken: string | undefined,
integrations: ScmIntegrations,
credentialsProvider: GitlabCredentialsProvider,
) {
this.#envToken = envToken;
this.#scmIntegrations = integrations;
this.#credentialsProvider = credentialsProvider;
}
name(): string {
return 'GitLab';
}
async discover(url: string): Promise<false | GitlabProject[]> {
const { origin, pathname } = new URL(url);
const [, user] = pathname.split('/');
const scmIntegration = this.#scmIntegrations.gitlab.byUrl(origin);
if (!scmIntegration) {
throw new Error(`No GitLab integration found for ${origin}`);
}
const headers = await this.#getRequestHeaders(origin);
const response = await fetch(
`${scmIntegration.config.apiBaseUrl}/users/${user}/projects`,
{ headers },
);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const projects: ProjectResponse[] = await response.json();
return projects.map(
project =>
new GitlabProject(project, scmIntegration.config.apiBaseUrl, headers),
);
}
async #getRequestHeaders(url: string): Promise<Record<string, string>> {
const credentials = await this.#credentialsProvider.getCredentials({
url,
});
if (credentials.headers) {
return credentials.headers;
} else if (credentials.token) {
return { authorization: `Bearer ${credentials.token}` };
}
if (this.#envToken) {
return { authorization: `Bearer ${this.#envToken}` };
}
throw new Error(
'No token available for GitLab, please set a GITLAB_TOKEN env variable',
);
}
}
@@ -1,38 +0,0 @@
/*
* Copyright 2023 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 { RepositoryFile } from '../types';
/**
* A single file in a GitLab repository.
*/
export class GitlabFile implements RepositoryFile {
readonly #path: string;
readonly #content: string;
constructor(path: string, content: string) {
this.#path = path;
this.#content = content;
}
get path(): string {
return this.#path;
}
async text(): Promise<string> {
return this.#content;
}
}
@@ -1,89 +0,0 @@
/*
* Copyright 2023 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 fetch from 'node-fetch';
import { GitlabFile } from './GitlabFile';
import { Repository, RepositoryFile } from '../types';
export type ProjectResponse = {
id: string;
name: string;
description: string;
owner: {
username: string;
};
web_url: string;
};
type BranchResponse = {
default: boolean;
name: string;
};
type FileContentResponse = {
content: string;
};
export class GitlabProject implements Repository {
constructor(
private readonly project: ProjectResponse,
private readonly apiBaseUrl: string,
private readonly headers: { [name: string]: string },
) {}
get url(): string {
return this.project.web_url;
}
get name(): string {
return this.project.name;
}
get owner(): string {
return this.project.owner.username;
}
get description(): string {
return this.project.description;
}
async file(filename: string): Promise<RepositoryFile | undefined> {
const mainBranch = await this.#getMainBranch();
const content = await this.#getFileContent(filename, mainBranch);
return new GitlabFile(filename, content);
}
async #getFileContent(path: string, mainBranch: string): Promise<string> {
const response = await fetch(
`${this.apiBaseUrl}/projects/${this.project.id}/repository/files/${path}?ref=${mainBranch}`,
{ headers: this.headers },
);
const { content }: FileContentResponse = await response.json();
return Buffer.from(content, 'base64').toString('ascii');
}
async #getMainBranch(): Promise<string> {
const response = await fetch(
`${this.apiBaseUrl}/projects/${this.project.id}/repository/branches`,
{ headers: this.headers },
);
const branches: BranchResponse[] = await response.json();
return branches.find(branch => branch.default)?.name ?? 'main';
}
}
@@ -1,53 +0,0 @@
/*
* Copyright 2023 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.
*/
/**
* Abstraction for a single repository.
*/
export interface Repository {
url: string;
name: string;
owner: string;
description?: string;
file(filename: string): Promise<RepositoryFile | undefined>;
}
/**
* Abstraction for a single repository file.
*/
export interface RepositoryFile {
/**
* The filepath of the data.
*/
path: string;
/**
* The textual contents of the file.
*/
text(): Promise<string>;
}
/**
* One integration that supports discovery of repositories.
*/
export interface Provider {
name(): string;
discover(url: string): Promise<Repository[] | false>;
}
@@ -1,36 +0,0 @@
/*
* Copyright 2023 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 { findPaths } from '@backstage/cli-common';
import * as path from 'path';
/* eslint-disable-next-line no-restricted-syntax */
const { targetRoot, ownDir } = findPaths(__dirname);
export const APP_CONFIG_FILE = path.join(targetRoot, 'app-config.local.yaml');
export const DISCOVERED_ENTITIES_FILE = path.join(
targetRoot,
'examples',
'discovered-entities.yaml',
);
export const PATCH_FOLDER = path.join(
ownDir,
'src',
'commands',
'onboard',
'auth',
'patches',
);
@@ -1,17 +0,0 @@
/*
* Copyright 2023 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 { command } from './command';
@@ -1,136 +0,0 @@
/*
* Copyright 2023 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 chalk from 'chalk';
import inquirer from 'inquirer';
import { Task } from '../../../lib/tasks';
import { updateConfigFile } from '../config';
import { APP_CONFIG_FILE } from '../files';
import { GitHubAnswers } from '../auth';
type Answers = {
hasEnterprise: boolean;
enterpriseInstanceUrl: string;
apiBaseUrl: string;
};
const getConfig = ({
hasEnterprise,
apiBaseUrl,
host,
token,
}: {
hasEnterprise: boolean;
apiBaseUrl: string;
host: string;
token: string;
}) => ({
integrations: {
github: [
{
host,
token,
...(hasEnterprise && {
apiBaseUrl,
}),
},
],
},
});
export const github = async (providerAnswers?: GitHubAnswers) => {
// TODO(tudi2d): Is GitHub Enterprise a valid setup if there is no Authentication?
const answers = await inquirer.prompt<Answers>([
{
type: 'confirm',
name: 'hasEnterprise',
message: 'Are you using GitHub Enterprise?',
when: () => typeof providerAnswers === 'undefined',
},
{
type: 'input',
name: 'enterpriseInstanceUrl',
message: 'What is your URL for GitHub Enterprise?',
when: ({ hasEnterprise }) => hasEnterprise,
validate: (input: string) => Boolean(new URL(input)),
},
{
type: 'input',
name: 'apiBaseUrl',
message: 'What is your GitHub Enterprise API path?',
default: '/api/v3',
when: ({ hasEnterprise }) =>
hasEnterprise || providerAnswers?.hasEnterprise,
// TODO(tudi2d): Fetch API using OAuth Token if Auth was set up
},
]);
const host = new URL(
providerAnswers?.enterpriseInstanceUrl ??
answers?.enterpriseInstanceUrl ??
'http://github.com',
);
Task.log(`
To create new repositories in GitHub using Software Templates you first need to create a personal access token: ${chalk.blue(
`${host.origin}/settings/tokens/new`,
)}
Select the following scopes:
Reading software components:${chalk.cyan(`
- "repo"`)}
Reading organization data:${chalk.cyan(`
- "read:org"
- "read:user"
- "user:email"`)}
Publishing software templates:${chalk.cyan(`
- "repo"
- "workflow" (if templates include GitHub workflows)
`)}
You can find the full documentation page here: ${chalk.blue(
'https://backstage.io/docs/integrations/github/locations',
)}
`);
const { token } = await inquirer.prompt<{ token: string }>([
{
type: 'input',
name: 'token',
message:
'Please insert your personal access token to setup the GitHub Integration',
// TODO(tudi2d): validate
},
]);
const config = getConfig({
hasEnterprise: providerAnswers?.hasEnterprise ?? answers.hasEnterprise,
apiBaseUrl: host.origin + answers.apiBaseUrl,
host: host.hostname,
token,
});
Task.log('Setting up Software Templates using GitHub integration for you...');
await Task.forItem(
'Updating',
APP_CONFIG_FILE,
async () => await updateConfigFile(APP_CONFIG_FILE, config),
);
};
@@ -1,75 +0,0 @@
/*
* Copyright 2023 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 inquirer from 'inquirer';
import { Task } from '../../../lib/tasks';
import { GitHubAnswers, GitLabAnswers } from '../auth';
import { github } from './github';
enum Integration {
GITHUB = 'GitHub',
}
const Integrations: Integration[] = [Integration.GITHUB];
export async function integrations(providerInfo?: {
provider: string;
answers: GitHubAnswers | GitLabAnswers;
}): Promise<void> {
const answers = await inquirer.prompt<{
integration?: Integration;
shouldUsePreviousProvider: boolean;
}>([
{
type: 'confirm',
name: 'shouldUsePreviousProvider',
message: `Do you want to keep using ${providerInfo?.provider} as your provider when setting up Software Templates?`,
when: () =>
providerInfo?.provider &&
Object.values(Integrations).includes(
providerInfo!.provider as Integration,
),
},
{
// TODO(tudi2d): Let's start with one, but it should be multiple choice in the future
type: 'list',
name: 'integration',
message: 'Please select an integration provider:',
choices: Integrations,
when: ({ shouldUsePreviousProvider }) => !shouldUsePreviousProvider,
},
]);
if (answers.shouldUsePreviousProvider) {
answers.integration = providerInfo!.provider as Integration;
}
switch (answers.integration) {
case Integration.GITHUB: {
const providerAnswers =
providerInfo?.provider === 'GitHub'
? (providerInfo!.answers as GitHubAnswers)
: undefined;
await github(providerAnswers);
break;
}
default:
}
Task.log();
Task.log(`Done setting up ${answers.integration} Integration!`);
Task.log();
}
-102
View File
@@ -1,102 +0,0 @@
/*
* Copyright 2020 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 { OptionValues } from 'commander';
import {
diffTemplateFiles,
handlers,
handleAllFiles,
inquirerPromptFunc,
makeCheckPromptFunc,
yesPromptFunc,
} from '../../lib/diff';
import { paths } from '../../lib/paths';
export type PluginData = {
id: string;
name: string;
privatePackage: string;
pluginVersion: string;
npmRegistry: string;
};
const fileHandlers = [
{
patterns: ['package.json'],
handler: handlers.packageJson,
},
{
// make sure files in 1st level of src/ and dev/ exist
patterns: ['.eslintrc.js'],
handler: handlers.exists,
},
{
patterns: ['README.md', 'tsconfig.json', /^src\//, /^dev\//],
handler: handlers.skip,
},
];
export default async (opts: OptionValues) => {
let promptFunc = inquirerPromptFunc;
let finalize = () => {};
if (opts.check) {
[promptFunc, finalize] = makeCheckPromptFunc();
} else if (opts.yes) {
promptFunc = yesPromptFunc;
}
const data = await readPluginData();
const templateFiles = await diffTemplateFiles('default-plugin', data);
await handleAllFiles(fileHandlers, templateFiles, promptFunc);
finalize();
};
// Reads templating data from the existing plugin
async function readPluginData(): Promise<PluginData> {
let name: string;
let privatePackage: string;
let pluginVersion: string;
let npmRegistry: string;
try {
const pkg = require(paths.resolveTarget('package.json'));
name = pkg.name;
privatePackage = pkg.private;
pluginVersion = pkg.version;
const scope = name.split('/')[0];
if (`${scope}:registry` in pkg.publishConfig) {
const registryURL = pkg.publishConfig[`${scope}:registry`];
npmRegistry = `"${scope}:registry" : "${registryURL}"`;
} else npmRegistry = '';
} catch (error) {
throw new Error(`Failed to read target package, ${error}`);
}
const pluginTsContents = await fs.readFile(
paths.resolveTarget('src/plugin.ts'),
'utf8',
);
// TODO: replace with some proper parsing logic or plugin metadata file
const pluginIdMatch = pluginTsContents.match(/id: ['"`](.+?)['"`]/);
if (!pluginIdMatch) {
throw new Error(`Failed to parse plugin.ts, no plugin ID found`);
}
const id = pluginIdMatch[1];
return { id, name, privatePackage, pluginVersion, npmRegistry };
}
-31
View File
@@ -1,31 +0,0 @@
/*
* Copyright 2020 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 { OptionValues } from 'commander';
import { run } from '../../lib/run';
export default async (opts: OptionValues) => {
const args = ['test'];
if (opts.watch) {
args.push('--watch');
}
if (opts.coverage) {
args.push('--coverage');
}
await run('web-scripts', args);
};
@@ -1,32 +0,0 @@
/*
* Copyright 2020 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 const pluginsFileContent = `
export { plugin as WelcomePlugin } from '@backstage/plugin-welcome';
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';`;
export const codeownersFileContent = `
* @spotify/backstage-core
/docs/features/techdocs @spotify/techdocs-core
/plugins/cost-insights @spotify/silver-lining
`;
export const packageFileContent = {
name: 'example-app',
version: '0.1.1',
dependencies: {},
devDependencies: {},
scripts: {},
};
@@ -1,21 +0,0 @@
/*
* Copyright 2020 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 default async () => {
throw new Error(
'This command has been removed, please consider alternatives such as `yarn dedupe` instead.',
);
};
-354
View File
@@ -1,354 +0,0 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import { diffLines } from 'diff';
import { sep, posix } from 'path';
import { FileDiff, PromptFunc, FileHandler, WriteFileFunc } from './types';
function sortObjectKeys(obj: Record<string, unknown>) {
const sortedKeys = Object.keys(obj).sort();
for (const key of sortedKeys) {
const value = obj[key];
delete obj[key];
obj[key] = value;
}
}
class PackageJsonHandler {
static async handler(
{ path, write, missing, targetContents, templateContents }: FileDiff,
prompt: PromptFunc,
variant?: string,
) {
console.log('Checking package.json');
if (missing) {
throw new Error(`${path} doesn't exist`);
}
const pkg = JSON.parse(templateContents);
const targetPkg = JSON.parse(targetContents);
const handler = new PackageJsonHandler(
write,
prompt,
pkg,
targetPkg,
variant,
);
await handler.handle();
}
static async appHandler(file: FileDiff, prompt: PromptFunc) {
return PackageJsonHandler.handler(file, prompt, 'app');
}
constructor(
private readonly writeFunc: WriteFileFunc,
private readonly prompt: PromptFunc,
private readonly pkg: any,
private readonly targetPkg: any,
private readonly variant?: string,
) {}
async handle() {
await this.syncField('main');
if (this.variant !== 'app') {
await this.syncField('main:src');
}
await this.syncField('types');
await this.syncFiles();
await this.syncScripts();
await this.syncPublishConfig();
await this.syncDependencies('dependencies');
await this.syncDependencies('peerDependencies', true);
await this.syncDependencies('devDependencies');
await this.syncReactDeps();
}
// Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change.
private async syncField(
fieldName: string,
obj: any = this.pkg,
targetObj: any = this.targetPkg,
prefix?: string,
sort?: boolean,
optional?: boolean,
) {
const fullFieldName = chalk.cyan(
prefix ? `${prefix}[${fieldName}]` : fieldName,
);
const newValue = obj[fieldName];
const coloredNewValue = chalk.cyan(JSON.stringify(newValue));
if (fieldName in targetObj) {
const oldValue = targetObj[fieldName];
if (JSON.stringify(oldValue) === JSON.stringify(newValue)) {
return;
}
const coloredOldValue = chalk.cyan(JSON.stringify(oldValue));
const msg = `package.json has mismatched field, ${fullFieldName}, change from ${coloredOldValue} to ${coloredNewValue}?`;
if (await this.prompt(msg)) {
targetObj[fieldName] = newValue;
if (sort) {
sortObjectKeys(targetObj);
}
await this.write();
}
} else if (fieldName in obj && optional !== true) {
if (
await this.prompt(
`package.json is missing field ${fullFieldName}, set to ${coloredNewValue}?`,
)
) {
targetObj[fieldName] = newValue;
if (sort) {
sortObjectKeys(targetObj);
}
await this.write();
}
}
}
private async syncFiles() {
const { configSchema } = this.targetPkg;
const hasSchemaFile = typeof configSchema === 'string';
if (!this.targetPkg.files) {
const expected = hasSchemaFile ? ['dist', configSchema] : ['dist'];
if (
await this.prompt(
`package.json is missing field "files", set to ${JSON.stringify(
expected,
)}?`,
)
) {
this.targetPkg.files = expected;
await this.write();
}
} else {
const missing = [];
if (!this.targetPkg.files.includes('dist')) {
missing.push('dist');
}
if (hasSchemaFile && !this.targetPkg.files.includes(configSchema)) {
missing.push(configSchema);
}
if (missing.length) {
if (
await this.prompt(
`package.json is missing ${JSON.stringify(
missing,
)} in the "files" field, add?`,
)
) {
this.targetPkg.files.push(...missing);
await this.write();
}
}
}
}
private async syncScripts() {
const pkgScripts = this.pkg.scripts;
const targetScripts = (this.targetPkg.scripts =
this.targetPkg.scripts || {});
if (!pkgScripts) {
return;
}
// Skip diffing package scripts that have been migrated to the new commands
const hasNewScript = Object.values(targetScripts).some(script =>
String(script).includes('backstage-cli package '),
);
if (hasNewScript) {
return;
}
for (const key of Object.keys(pkgScripts)) {
await this.syncField(key, pkgScripts, targetScripts, 'scripts');
}
}
private async syncPublishConfig() {
const pkgPublishConf = this.pkg.publishConfig;
const targetPublishConf = this.targetPkg.publishConfig;
// If template doesn't have a publish config we're done
if (!pkgPublishConf) {
return;
}
// Publish config can be removed the the target, skip in that case
if (!targetPublishConf) {
if (await this.prompt('Missing publishConfig, do you want to add it?')) {
this.targetPkg.publishConfig = pkgPublishConf;
await this.write();
}
return;
}
for (const key of Object.keys(pkgPublishConf)) {
// Don't want to mess with peoples internal setup
if (!['access', 'registry'].includes(key)) {
await this.syncField(
key,
pkgPublishConf,
targetPublishConf,
'publishConfig',
);
}
}
}
private async syncDependencies(fieldName: string, required: boolean = false) {
const pkgDeps = this.pkg[fieldName];
const targetDeps = (this.targetPkg[fieldName] =
this.targetPkg[fieldName] || {});
if (!pkgDeps && !required) {
return;
}
// Hardcoded removal of these during migration
await this.syncField('@backstage/core', {}, targetDeps, fieldName, true);
await this.syncField(
'@backstage/core-api',
{},
targetDeps,
fieldName,
true,
);
for (const key of Object.keys(pkgDeps)) {
if (this.variant === 'app' && key.startsWith('plugin-')) {
continue;
}
await this.syncField(
key,
pkgDeps,
targetDeps,
fieldName,
true,
!required,
);
}
}
private async syncReactDeps() {
const targetDeps = (this.targetPkg.dependencies =
this.targetPkg.dependencies || {});
// Remove these from from deps since they're now in peerDeps
await this.syncField('react', {}, targetDeps, 'dependencies');
await this.syncField('react-dom', {}, targetDeps, 'dependencies');
}
private async write() {
await this.writeFunc(`${JSON.stringify(this.targetPkg, null, 2)}\n`);
}
}
// Make sure the file is an exact match of the template
async function exactMatchHandler(
{ path, write, missing, targetContents, templateContents }: FileDiff,
prompt: PromptFunc,
) {
console.log(`Checking ${path}`);
const coloredPath = chalk.cyan(path);
if (missing) {
if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) {
await write(templateContents);
}
return;
}
if (targetContents === templateContents) {
return;
}
const diffs = diffLines(targetContents, templateContents);
for (const diff of diffs) {
if (diff.added) {
process.stdout.write(chalk.green(`+${diff.value}`));
} else if (diff.removed) {
process.stdout.write(chalk.red(`-${diff.value}`));
} else {
process.stdout.write(` ${diff.value}`);
}
}
if (
await prompt(
`Outdated ${coloredPath}, do you want to apply the above patch?`,
)
) {
await write(templateContents);
}
}
// Adds the file if it is missing, but doesn't check existing files
async function existsHandler(
{ path, write, missing, templateContents }: FileDiff,
prompt: PromptFunc,
) {
console.log(`Making sure ${path} exists`);
const coloredPath = chalk.cyan(path);
if (missing) {
if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) {
await write(templateContents);
}
return;
}
}
async function skipHandler({ path }: FileDiff) {
console.log(`Skipping ${path}`);
}
export const handlers = {
skip: skipHandler,
exists: existsHandler,
exactMatch: exactMatchHandler,
packageJson: PackageJsonHandler.handler,
appPackageJson: PackageJsonHandler.appHandler,
};
export async function handleAllFiles(
fileHandlers: FileHandler[],
files: FileDiff[],
promptFunc: PromptFunc,
) {
for (const file of files) {
const path = file.path.split(sep).join(posix.sep);
const fileHandler = fileHandlers.find(handler =>
handler.patterns.some(pattern =>
typeof pattern === 'string' ? pattern === path : pattern.test(path),
),
);
if (fileHandler) {
await fileHandler.handler(file, promptFunc);
} else {
throw new Error(`No template file handler found for ${path}`);
}
}
}
-20
View File
@@ -1,20 +0,0 @@
/*
* Copyright 2020 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 * from './handlers';
export * from './prompts';
export * from './read';
export * from './types';
-53
View File
@@ -1,53 +0,0 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import inquirer from 'inquirer';
import { PromptFunc } from './types';
export const inquirerPromptFunc: PromptFunc = async msg => {
const { result } = await inquirer.prompt({
type: 'confirm',
name: 'result',
message: chalk.blue(msg),
});
return result;
};
export const makeCheckPromptFunc = () => {
let failed = false;
const promptFunc: PromptFunc = async msg => {
failed = true;
console.log(chalk.red(`[Check Failed] ${msg}`));
return false;
};
const finalize = () => {
if (failed) {
throw new Error(
'Check failed, the plugin is not in sync with the latest template',
);
}
};
return [promptFunc, finalize] as const;
};
export const yesPromptFunc: PromptFunc = async msg => {
console.log(`Accepting: "${msg}"`);
return true;
};
-120
View File
@@ -1,120 +0,0 @@
/*
* Copyright 2020 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 {
dirname,
resolve as resolvePath,
relative as relativePath,
} from 'path';
import handlebars from 'handlebars';
import recursiveReadDir from 'recursive-readdir';
import { paths } from '../paths';
import { FileDiff } from './types';
import { createPackageVersionProvider } from '../../lib/version';
export type TemplatedFile = {
path: string;
contents: string;
};
async function readTemplateFile(
templateFile: string,
templateVars: any,
): Promise<string> {
const contents = await fs.readFile(templateFile, 'utf8');
if (!templateFile.endsWith('.hbs')) {
return contents;
}
const packageVersionProvider = createPackageVersionProvider(undefined);
return handlebars.compile(contents)(templateVars, {
helpers: {
versionQuery(name: string, hint: string | unknown) {
return packageVersionProvider(
name,
typeof hint === 'string' ? hint : undefined,
);
},
},
});
}
async function readTemplate(
templateDir: string,
templateVars: any,
): Promise<TemplatedFile[]> {
const templateFilePaths = await recursiveReadDir(templateDir).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
const templatedFiles = new Array<TemplatedFile>();
for (const templateFile of templateFilePaths) {
const path = relativePath(templateDir, templateFile).replace(/\.hbs$/, '');
const contents = await readTemplateFile(templateFile, templateVars);
templatedFiles.push({ path, contents });
}
return templatedFiles;
}
async function diffTemplatedFiles(
targetDir: string,
templatedFiles: TemplatedFile[],
): Promise<FileDiff[]> {
const fileDiffs = new Array<FileDiff>();
for (const { path, contents: templateContents } of templatedFiles) {
const targetPath = resolvePath(targetDir, path);
const targetExists = await fs.pathExists(targetPath);
const write = async (contents: string) => {
await fs.ensureDir(dirname(targetPath));
await fs.writeFile(targetPath, contents, 'utf8');
};
if (targetExists) {
const targetContents = await fs.readFile(targetPath, 'utf8');
fileDiffs.push({
path,
write,
missing: false,
targetContents,
templateContents,
});
} else {
fileDiffs.push({
path,
write,
missing: true,
targetContents: '',
templateContents,
});
}
}
return fileDiffs;
}
// Read all template files for a given template, along with all matching files in the target dir
export async function diffTemplateFiles(template: string, templateData: any) {
const templateDir = paths.resolveOwn('templates', template);
const templatedFiles = await readTemplate(templateDir, templateData);
const fileDiffs = await diffTemplatedFiles(paths.targetDir, templatedFiles);
return fileDiffs;
}
-39
View File
@@ -1,39 +0,0 @@
/*
* Copyright 2020 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 type WriteFileFunc = (contents: string) => Promise<void>;
export type FileDiff = {
// Relative path within the target directory
path: string;
// Whether the target file exists in the target directory.
missing: boolean;
// Contents of the file in the target directory, or an empty string if the file is missing.
targetContents: string;
// Contents of the compiled template file
templateContents: string;
// Write new contents to the target file
write: WriteFileFunc;
};
export type PromptFunc = (msg: string) => Promise<boolean>;
export type HandlerFunc = (file: FileDiff, prompt: PromptFunc) => Promise<void>;
export type FileHandler = {
patterns: Array<string | RegExp>;
handler: HandlerFunc;
};
-58
View File
@@ -1,58 +0,0 @@
/*
* Copyright 2020 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 type LogFunc = (data: Buffer) => void;
export type LogPipe = (dst: NodeJS.WriteStream) => LogFunc;
export type LogOptions = {
// If set, prefix each log message with this string
prefix?: string;
// If true, clear terminal commands will be forwarded, otherwise they are removed
forwardClearTerm?: boolean;
};
// Creates a log pipe that binds to a destination stream and forwards logs with optional transforms.
// Use returned logPipe e.g. as follows: child.stdout.on('data', logPipe(process.stdout))
export function createLogPipe(options: LogOptions = {}): LogPipe {
const { prefix = '', forwardClearTerm = false } = options;
return (dst: NodeJS.WriteStream) => (data: Buffer | string) => {
let str = typeof data === 'string' ? data : data.toString('utf8');
if (!forwardClearTerm) {
str = trimClearTerm(str);
}
if (prefix) {
str = `${prefix}${str}`;
}
dst.write(Buffer.from(str, 'utf8'));
};
}
// Wrapper around createLogPipe to avoid awkward immediate call of returned function
export function createLogFunc(
dst: NodeJS.WriteStream,
options: LogOptions = {},
): LogFunc {
return createLogPipe(options)(dst);
}
// Returns the string without terminal clear command if it was prefixed with one.
export function trimClearTerm(msg: string): string {
return msg.startsWith('\x1b\x63') ? msg.slice(2) : msg;
}
+2 -1
View File
@@ -22,11 +22,12 @@ import {
} from 'child_process';
import { ExitCodeError } from './errors';
import { promisify } from 'util';
import { LogFunc } from './logging';
import { assertError, ForwardedError } from '@backstage/errors';
export const execFile = promisify(execFileCb);
type LogFunc = (data: Buffer) => void;
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
// Pipe stdout to this log function