Merge pull request #26811 from backstage/rugvip/cleanup
cli: remove deprecated and experimental commands
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 Catalog’s entity page
|
||||
* sidebar-item: Add a sidebar item
|
||||
* frontend-api: Add a custom API
|
||||
*/
|
||||
|
||||
/** A serialized install step as it appears in JSON */
|
||||
export type SerializedStep = {
|
||||
type: string;
|
||||
} & unknown;
|
||||
|
||||
export type InstallationRecipe = {
|
||||
type?: 'frontend' | 'backend';
|
||||
steps: SerializedStep[];
|
||||
peerPluginDependencies: { [pluginId: string]: string };
|
||||
};
|
||||
|
||||
/** package.json data */
|
||||
export type PackageWithInstallRecipe = YarnInfoInspectData & {
|
||||
version: string;
|
||||
experimentalInstallationRecipe?: InstallationRecipe;
|
||||
};
|
||||
|
||||
export type PeerPluginDependencies = Map<
|
||||
string,
|
||||
{
|
||||
pkg: PackageWithInstallRecipe;
|
||||
versionToInstall?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface Step {
|
||||
run(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StepDefinition<Options> {
|
||||
/** The string identifying this type of step */
|
||||
type: string;
|
||||
|
||||
/** Deserializes and validate a JSON description of the step data */
|
||||
deserialize(obj: JsonObject, pkg: PackageWithInstallRecipe): Step;
|
||||
|
||||
/** Creates a step using known parameters */
|
||||
create(options: Options): Step;
|
||||
}
|
||||
|
||||
/** Creates a new step definition. Only used as a helper for type inference */
|
||||
export function createStepDefinition<T>(
|
||||
config: StepDefinition<T>,
|
||||
): StepDefinition<T> {
|
||||
return config;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
-163
@@ -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;
|
||||
}
|
||||
}
|
||||
-106
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user