Merge branch 'master' into feat/rspack
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -1,5 +1,230 @@
|
||||
# @backstage/cli
|
||||
|
||||
## 0.28.0-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea16633: Preserve directory structure for CommonJS build output, just like ESM. This makes the build output more stable and easier to browse, and allows for more effective tree shaking and lazy imports.
|
||||
- 7955f9b: Tweaked the new package feature detection to not be active when building backend packages.
|
||||
- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`.
|
||||
- 2c5ecf5: Support `--max-warnings` flag for package linting
|
||||
- 8fe740d: Added a new `--successCache` option to the `backstage-cli repo test` and `backstage-cli repo lint` commands. The cache keeps track of successful runs and avoids re-running for individual packages if they haven't changed. This option is intended only to be used in CI.
|
||||
|
||||
In addition a `--successCacheDir <path>` option has also been added to be able to override the default cache directory.
|
||||
|
||||
- f0514c7: Disabled parsing of input source maps in the SWC transform for Jest.
|
||||
- Updated dependencies
|
||||
- @backstage/cli-node@0.2.9-next.0
|
||||
- @backstage/eslint-plugin@0.1.10-next.1
|
||||
- @backstage/integration@1.15.1-next.1
|
||||
- @backstage/catalog-model@1.7.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.1
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.28.0-next.1
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 55b8b84: **BREAKING**: The Jest configuration defined at `@backstage/cli/config/jest` no longer collects configuration defined in the `"jest"` field from all parent `package.json` files. Instead, it will only read and merge configuration from the `package.json` in the monorepo root if it exists, as well as the target package. In addition, configuration defined in the root `package.json` will now only be merged into each package configuration if it is a valid project-level configuration key.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 03810d2: Remove unknown dependency `diff`
|
||||
- cebee4f: Added support for a new experimental `EXPERIMENTAL_TRIM_NEXT_ENTRY` flag which removes any `./next` entry points present in packages when building and publishing.
|
||||
- 55b8b84: The Jest configuration will now search for a `src/setupTests.*` file with any valid script extension, not only `.ts`.
|
||||
- Updated dependencies
|
||||
- @backstage/eslint-plugin@0.1.10-next.0
|
||||
- @backstage/integration@1.15.1-next.0
|
||||
- @backstage/catalog-model@1.7.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.8
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.1
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.28.0-next.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6129076: **BREAKING**: Removed the following deprecated commands:
|
||||
|
||||
- `create`: Use `backstage-cli new` instead
|
||||
- `create-plugin`: Use `backstage-cli new` instead
|
||||
- `plugin:diff`: Use `backstage-cli fix` instead
|
||||
- `test`: Use `backstage-cli repo test` or `backstage-cli package test` instead
|
||||
- `versions:check`: Use `yarn dedupe` or `yarn-deduplicate` instead
|
||||
- `clean`: Use `backstage-cli package clean` instead
|
||||
|
||||
In addition, the experimental `install` and `onboard` commands have been removed since they have not received any updates since their introduction and we're expecting usage to be low. If you where relying on these commands, please let us know by opening an issue towards the main Backstage repository.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 520a383: Added functionality to the prepack script that will append the default export type for entry points to the `exports` object before publishing. This is to help with identifying the declarative integration points for plugins without needing to fetch or run the plugins first.
|
||||
- 094eaa3: Remove references to in-repo backend-common
|
||||
- 79ba5a8: The `LEGACY_BACKEND_START` flag is now deprecated.
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.7.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.8
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.1
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/eslint-plugin@0.1.9
|
||||
- @backstage/integration@1.15.0
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d2d2313: Add `config.d.ts` files to the list of included file in `tsconfig.json`.
|
||||
|
||||
This allows ESLint to detect issues or deprecations in those files.
|
||||
|
||||
- 16ffdd6: Remove direct `vite` dependency
|
||||
- 8069f4a: Update Scaffolder module template to add itself to the backend
|
||||
- 97422b0: Update templates to not refer to backend-common
|
||||
- 0e1a817: The app build process now outputs an additional `index.html.tmpl` file. This is an non-templated version of the `index.html` file, which can be used to delay templating until runtime.
|
||||
|
||||
The new `index.html.tmpl` file also sets a `backstage-public-path` meta tag to be templated at runtime. The meta tag is in turn picked up by the new `@backstage/cli/config/webpack-public-path.js` entry point script, which uses it to set the runtime public path of the Webpack bundle.
|
||||
|
||||
- 1b5c264: Add `checks: 'read'` for default GitHub app permissions
|
||||
- b4685e7: Added `watchOptions` to frontend webpack config for compatibility with Yarn PnP
|
||||
- d29fc1b: Updated dependency `@module-federation/enhanced` to `^0.6.0`.
|
||||
- f865103: Updated dependency `esbuild` to `^0.23.0`.
|
||||
- ab7713a: Updated dependency `eslint-plugin-jest` to `^28.0.0`.
|
||||
- c78ff91: Updated dependency `@rollup/plugin-commonjs` to `^26.0.0`.
|
||||
- 4ebf36f: Upgrade to `vite@v5`
|
||||
- 2d3caaf: The build commands now support the new `backstage.inline` flag in `package.json`, which causes the contents of private packages to be inlined into the consuming package, rather than be treated as an external dependency.
|
||||
- 569c3f0: Fixed an issue where published frontend packages would end up with an invalid import structure if a single module imported both `.css` and `.svg` files.
|
||||
- 3d88455: Add support for `backstage:^` version ranges to versions:bump when using the experimental yarn plugin
|
||||
- d10f6b6: Allow overriding minify flag with build repo command
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.7.0
|
||||
- @backstage/cli-node@0.2.8
|
||||
- @backstage/integration@1.15.0
|
||||
- @backstage/config-loader@1.9.1
|
||||
- @backstage/eslint-plugin@0.1.9
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.1-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 16ffdd6: Remove direct `vite` dependency
|
||||
- 8069f4a: Update Scaffolder module template to add itself to the backend
|
||||
- 0e1a817: The app build process now outputs an additional `index.html.tmpl` file. This is an non-templated version of the `index.html` file, which can be used to delay templating until runtime.
|
||||
|
||||
The new `index.html.tmpl` file also sets a `backstage-public-path` meta tag to be templated at runtime. The meta tag is in turn picked up by the new `@backstage/cli/config/webpack-public-path.js` entry point script, which uses it to set the runtime public path of the Webpack bundle.
|
||||
|
||||
- d29fc1b: Updated dependency `@module-federation/enhanced` to `^0.6.0`.
|
||||
- 4ebf36f: Upgrade to `vite@v5`
|
||||
- 2d3caaf: The build commands now support the new `backstage.inline` flag in `package.json`, which causes the contents of private packages to be inlined into the consuming package, rather than be treated as an external dependency.
|
||||
- 3d88455: Add support for `backstage:^` version ranges to versions:bump when using the experimental yarn plugin
|
||||
- Updated dependencies
|
||||
- @backstage/cli-node@0.2.8-next.0
|
||||
- @backstage/integration@1.15.0-next.0
|
||||
- @backstage/config-loader@1.9.1-next.0
|
||||
- @backstage/eslint-plugin@0.1.9-next.0
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d2d2313: Add `config.d.ts` files to the list of included file in `tsconfig.json`.
|
||||
|
||||
This allows ESLint to detect issues or deprecations in those files.
|
||||
|
||||
- 97422b0: Update templates to not refer to backend-common
|
||||
- f865103: Updated dependency `esbuild` to `^0.23.0`.
|
||||
- 569c3f0: Fixed an issue where published frontend packages would end up with an invalid import structure if a single module imported both `.css` and `.svg` files.
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/eslint-plugin@0.1.8
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1b5c264: Add `checks: 'read'` for default GitHub app permissions
|
||||
- Updated dependencies
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/eslint-plugin@0.1.8
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 32a38e1: **BREAKING**: The lockfile (`yarn.lock`) dependency analysis and mutations have been removed from several commands.
|
||||
|
||||
The `versions:bump` command will no longer attempt to bump and deduplicate dependencies by modifying the lockfile, it will only update `package.json` files.
|
||||
|
||||
The `versions:check` command has been removed, since its only purpose was verification and mutation of the lockfile. We recommend using the `yarn dedupe` command instead, or the `yarn-deduplicate` package if you're using Yarn classic.
|
||||
|
||||
The check that was built into the `package start` command has been removed, it will no longer warn about lockfile mismatches.
|
||||
|
||||
The packages in the Backstage ecosystem handle package duplications much better now than when these CLI features were first introduced, so the need for these features has diminished. By removing them, we drastically reduce the integration between the Backstage CLI and Yarn, making it much easier to add support for other package managers in the future.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7eb08a6: Add frontend-dynamic-container role to eslint config factory
|
||||
- b2d97fd: Fixing loading of additional config files with new `ConfigSources`
|
||||
- fbc7819: Use ES2022 in CLI bundler
|
||||
- 93095ee: Make sure node-fetch is version 2.7.0 or greater
|
||||
- 6d898d8: Switched the `process` polyfill to use `require.resolve` for greater compatability.
|
||||
- e53074f: Updated default backend plugin to use `RootConfigService` instead of `Config`. This also removes the dependency on `@backstage/config` as it's no longer used.
|
||||
- ee2b0e5: The experimental module federation build now has the ability to force the use of development versions of `react` and `react-dom` by setting the `FORCE_REACT_DEVELOPMENT` flag.
|
||||
- 239dffc: Remove usage of deprecated functionality from @backstage/config-loader
|
||||
- e6e7d86: Switched the target from `'ES2022'` to `'es2022'` for better compatibility with older versions of `swc`.
|
||||
- 2ced236: Updated dependency `@module-federation/enhanced` to `0.3.1`
|
||||
- 0eedec3: Add support for dynamic plugins via the EXPERIMENTAL_MODULE_FEDERATION environment variable when running `yarn start`.
|
||||
- adabb40: New command now supports setting package license
|
||||
- dc4fb4f: Fix for `repo build --all` not properly detecting the experimental public entry point.
|
||||
- Updated dependencies
|
||||
- @backstage/config-loader@1.9.0
|
||||
- @backstage/integration@1.14.0
|
||||
- @backstage/catalog-model@1.6.0
|
||||
- @backstage/cli-common@0.1.14
|
||||
- @backstage/cli-node@0.2.7
|
||||
- @backstage/config@1.2.0
|
||||
- @backstage/errors@1.2.4
|
||||
- @backstage/eslint-plugin@0.1.8
|
||||
- @backstage/release-manifests@0.0.11
|
||||
- @backstage/types@1.1.1
|
||||
|
||||
## 0.27.0-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+6
-125
@@ -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`
|
||||
|
||||
```
|
||||
@@ -248,6 +237,7 @@ Usage: backstage-cli package lint [options] [directories...]
|
||||
Options:
|
||||
--format <format>
|
||||
--fix
|
||||
--max-warnings <number>
|
||||
-h, --help
|
||||
```
|
||||
|
||||
@@ -424,6 +414,7 @@ Usage: backstage-cli repo build [options]
|
||||
Options:
|
||||
--all
|
||||
--since <ref>
|
||||
--minify
|
||||
-h, --help
|
||||
```
|
||||
|
||||
@@ -455,6 +446,8 @@ Usage: backstage-cli repo lint [options]
|
||||
Options:
|
||||
--format <format>
|
||||
--since <ref>
|
||||
--successCache
|
||||
--successCacheDir <path>
|
||||
--fix
|
||||
-h, --help
|
||||
```
|
||||
@@ -476,124 +469,12 @@ Usage: backstage-cli repo test [options]
|
||||
|
||||
Options:
|
||||
--since <ref>
|
||||
--successCache
|
||||
--successCacheDir <path>
|
||||
--jest-help
|
||||
-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`
|
||||
|
||||
```
|
||||
|
||||
@@ -277,7 +277,7 @@ function createConfigForRole(dir, role, extraConfig = {}) {
|
||||
restrictedSrcSyntax: [
|
||||
{
|
||||
message:
|
||||
"`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-common` instead.",
|
||||
"`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-plugin-api` instead.",
|
||||
selector: 'Identifier[name="__dirname"]',
|
||||
},
|
||||
...(extraConfig.restrictedSrcSyntax ?? []),
|
||||
|
||||
+123
-69
@@ -19,6 +19,9 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const glob = require('util').promisify(require('glob'));
|
||||
const { version } = require('../package.json');
|
||||
const paths = require('@backstage/cli-common').findPaths(process.cwd());
|
||||
|
||||
const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs'];
|
||||
|
||||
const envOptions = {
|
||||
oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS),
|
||||
@@ -31,6 +34,75 @@ try {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of config keys that are valid for project-level config.
|
||||
* Jest will complain if we forward any other root configuration to the projects.
|
||||
*
|
||||
* @type {Array<keyof import('@jest/types').Config.ProjectConfig>}
|
||||
*/
|
||||
const projectConfigKeys = [
|
||||
'automock',
|
||||
'cache',
|
||||
'cacheDirectory',
|
||||
'clearMocks',
|
||||
'collectCoverageFrom',
|
||||
'coverageDirectory',
|
||||
'coveragePathIgnorePatterns',
|
||||
'cwd',
|
||||
'dependencyExtractor',
|
||||
'detectLeaks',
|
||||
'detectOpenHandles',
|
||||
'displayName',
|
||||
'errorOnDeprecated',
|
||||
'extensionsToTreatAsEsm',
|
||||
'fakeTimers',
|
||||
'filter',
|
||||
'forceCoverageMatch',
|
||||
'globalSetup',
|
||||
'globalTeardown',
|
||||
'globals',
|
||||
'haste',
|
||||
'id',
|
||||
'injectGlobals',
|
||||
'moduleDirectories',
|
||||
'moduleFileExtensions',
|
||||
'moduleNameMapper',
|
||||
'modulePathIgnorePatterns',
|
||||
'modulePaths',
|
||||
'openHandlesTimeout',
|
||||
'preset',
|
||||
'prettierPath',
|
||||
'resetMocks',
|
||||
'resetModules',
|
||||
'resolver',
|
||||
'restoreMocks',
|
||||
'rootDir',
|
||||
'roots',
|
||||
'runner',
|
||||
'runtime',
|
||||
'sandboxInjectedGlobals',
|
||||
'setupFiles',
|
||||
'setupFilesAfterEnv',
|
||||
'skipFilter',
|
||||
'skipNodeResolution',
|
||||
'slowTestThreshold',
|
||||
'snapshotResolver',
|
||||
'snapshotSerializers',
|
||||
'snapshotFormat',
|
||||
'testEnvironment',
|
||||
'testEnvironmentOptions',
|
||||
'testMatch',
|
||||
'testLocationInResults',
|
||||
'testPathIgnorePatterns',
|
||||
'testRegex',
|
||||
'testRunner',
|
||||
'transform',
|
||||
'transformIgnorePatterns',
|
||||
'watchPathIgnorePatterns',
|
||||
'unmockedModulePathPatterns',
|
||||
'workerIdleMemoryLimit',
|
||||
];
|
||||
|
||||
const transformIgnorePattern = [
|
||||
'@material-ui',
|
||||
'ajv',
|
||||
@@ -76,54 +148,11 @@ async function getProjectConfig(targetPath, extraConfig) {
|
||||
return require(configTsPath);
|
||||
}
|
||||
|
||||
// We read all "jest" config fields in package.json files all the way to the filesystem root.
|
||||
// All configs are merged together to create the final config, with longer paths taking precedence.
|
||||
// Jest config can be defined both in the root package.json and within each package. The root config
|
||||
// gets forwarded to us through the `extraConfig` parameter, while the package config is read here.
|
||||
// If they happen to be the same the keys will simply override each other.
|
||||
// The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined.
|
||||
const pkgJsonConfigs = [];
|
||||
let closestPkgJson = undefined;
|
||||
let currentPath = targetPath;
|
||||
|
||||
// Some confidence check to avoid infinite loop
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const packagePath = path.resolve(currentPath, 'package.json');
|
||||
const exists = fs.pathExistsSync(packagePath);
|
||||
if (exists) {
|
||||
try {
|
||||
const data = fs.readJsonSync(packagePath);
|
||||
if (!closestPkgJson) {
|
||||
closestPkgJson = data;
|
||||
}
|
||||
if (data.jest) {
|
||||
pkgJsonConfigs.unshift(data.jest);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse package.json file reading jest configs, ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = path.dirname(currentPath);
|
||||
if (newPath === currentPath) {
|
||||
break;
|
||||
}
|
||||
currentPath = newPath;
|
||||
}
|
||||
|
||||
// This is an old deprecated option that is no longer used.
|
||||
const transformModules = pkgJsonConfigs
|
||||
.flatMap(conf => {
|
||||
const modules = conf.transformModules || [];
|
||||
delete conf.transformModules;
|
||||
return modules;
|
||||
})
|
||||
.map(name => `${name}/`)
|
||||
.join('|');
|
||||
if (transformModules.length > 0) {
|
||||
console.warn(
|
||||
'The Backstage CLI jest transformModules option is no longer used and will be ignored. All modules are now always transformed.',
|
||||
);
|
||||
}
|
||||
const pkgJson = await fs.readJson(path.resolve(targetPath, 'package.json'));
|
||||
|
||||
const options = {
|
||||
...extraConfig,
|
||||
@@ -191,14 +220,14 @@ async function getProjectConfig(targetPath, extraConfig) {
|
||||
},
|
||||
|
||||
// A bit more opinionated
|
||||
testMatch: ['**/*.test.{js,jsx,ts,tsx,mjs,cjs}'],
|
||||
testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`],
|
||||
|
||||
runtime: envOptions.oldTests
|
||||
? undefined
|
||||
: require.resolve('./jestCachingModuleLoader'),
|
||||
|
||||
transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`],
|
||||
...getRoleConfig(closestPkgJson?.backstage?.role),
|
||||
...getRoleConfig(pkgJson.backstage?.role),
|
||||
};
|
||||
|
||||
options.setupFilesAfterEnv = options.setupFilesAfterEnv || [];
|
||||
@@ -208,12 +237,15 @@ async function getProjectConfig(targetPath, extraConfig) {
|
||||
options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill'));
|
||||
}
|
||||
|
||||
// Use src/setupTests.ts as the default location for configuring test env
|
||||
if (fs.existsSync(path.resolve(targetPath, 'src/setupTests.ts'))) {
|
||||
options.setupFilesAfterEnv.push('<rootDir>/setupTests.ts');
|
||||
// Use src/setupTests.* as the default location for configuring test env
|
||||
for (const ext of SRC_EXTS) {
|
||||
if (fs.existsSync(path.resolve(targetPath, `src/setupTests.${ext}`))) {
|
||||
options.setupFilesAfterEnv.push(`<rootDir>/setupTests.${ext}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const config = Object.assign(options, ...pkgJsonConfigs);
|
||||
const config = Object.assign(options, pkgJson.jest);
|
||||
|
||||
// The config id is a cache key that lets us share the jest cache across projects.
|
||||
// If no explicit id was configured, generated one based on the configuration.
|
||||
@@ -234,34 +266,47 @@ async function getProjectConfig(targetPath, extraConfig) {
|
||||
// configuration for the current package, or a collection of configurations for
|
||||
// the target workspace packages
|
||||
async function getRootConfig() {
|
||||
const targetPath = process.cwd();
|
||||
const targetPackagePath = path.resolve(targetPath, 'package.json');
|
||||
const exists = await fs.pathExists(targetPackagePath);
|
||||
const rootPkgJson = await fs.readJson(
|
||||
paths.resolveTargetRoot('package.json'),
|
||||
);
|
||||
|
||||
const coverageConfig = {
|
||||
coverageDirectory: path.resolve(targetPath, 'coverage'),
|
||||
const baseCoverageConfig = {
|
||||
coverageDirectory: paths.resolveTarget('coverage'),
|
||||
coverageProvider: envOptions.oldTests ? 'v8' : 'babel',
|
||||
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'],
|
||||
};
|
||||
|
||||
if (!exists) {
|
||||
return getProjectConfig(targetPath, coverageConfig);
|
||||
const workspacePatterns =
|
||||
rootPkgJson.workspaces && rootPkgJson.workspaces.packages;
|
||||
|
||||
// Check if we're running within a specific monorepo package. In that case just get the single project config.
|
||||
if (!workspacePatterns || paths.targetRoot !== paths.targetDir) {
|
||||
return getProjectConfig(paths.targetDir, {
|
||||
...baseCoverageConfig,
|
||||
...(rootPkgJson.jest ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the current package is a workspace root or not
|
||||
const data = await fs.readJson(targetPackagePath);
|
||||
const workspacePatterns = data.workspaces && data.workspaces.packages;
|
||||
if (!workspacePatterns) {
|
||||
return getProjectConfig(targetPath, coverageConfig);
|
||||
const globalRootConfig = { ...baseCoverageConfig };
|
||||
const globalProjectConfig = {};
|
||||
|
||||
for (const [key, value] of Object.entries(rootPkgJson.jest ?? {})) {
|
||||
if (projectConfigKeys.includes(key)) {
|
||||
globalProjectConfig[key] = value;
|
||||
} else {
|
||||
globalRootConfig[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// If the target package is a workspace root, we find all packages in the
|
||||
// workspace and load those in as separate jest projects instead.
|
||||
const projectPaths = await Promise.all(
|
||||
workspacePatterns.map(pattern => glob(path.join(targetPath, pattern))),
|
||||
workspacePatterns.map(pattern =>
|
||||
glob(path.join(paths.targetRoot, pattern)),
|
||||
),
|
||||
).then(_ => _.flat());
|
||||
|
||||
const configs = await Promise.all(
|
||||
let configs = await Promise.all(
|
||||
projectPaths.flat().map(async projectPath => {
|
||||
const packagePath = path.resolve(projectPath, 'package.json');
|
||||
if (!(await fs.pathExists(packagePath))) {
|
||||
@@ -277,6 +322,7 @@ async function getRootConfig() {
|
||||
testScript?.includes('backstage-cli package test');
|
||||
if (testScript && isSupportedTestScript) {
|
||||
return await getProjectConfig(projectPath, {
|
||||
...globalProjectConfig,
|
||||
displayName: packageData.name,
|
||||
});
|
||||
}
|
||||
@@ -285,10 +331,18 @@ async function getRootConfig() {
|
||||
}),
|
||||
).then(cs => cs.filter(Boolean));
|
||||
|
||||
const cache = global.__backstageCli_jestSuccessCache;
|
||||
if (cache) {
|
||||
configs = await cache.filterConfigs(configs, globalRootConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
rootDir: targetPath,
|
||||
rootDir: paths.targetRoot,
|
||||
projects: configs,
|
||||
...coverageConfig,
|
||||
testResultsProcessor: cache
|
||||
? require.resolve('./jestCacheResultProcessor.cjs')
|
||||
: undefined,
|
||||
...globalRootConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2024 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.
|
||||
@@ -14,8 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export default async () => {
|
||||
throw new Error(
|
||||
'This command has been removed, please consider alternatives such as `yarn dedupe` instead.',
|
||||
);
|
||||
module.exports = async results => {
|
||||
const cache = global.__backstageCli_jestSuccessCache;
|
||||
if (cache) {
|
||||
await cache.reportResults(results);
|
||||
}
|
||||
return results;
|
||||
};
|
||||
@@ -18,7 +18,10 @@ const { createTransformer: createSwcTransformer } = require('@swc/jest');
|
||||
const ESM_REGEX = /\b(?:import|export)\b/;
|
||||
|
||||
function createTransformer(config) {
|
||||
const swcTransformer = createSwcTransformer(config);
|
||||
const swcTransformer = createSwcTransformer({
|
||||
inputSourceMap: false,
|
||||
...config,
|
||||
});
|
||||
const process = (source, filePath, jestOptions) => {
|
||||
if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) {
|
||||
return { code: source };
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
// This script is used to pick up and set the public path of the Webpack bundle
|
||||
// at runtime. The meta tag is injected by the app build, but only present in
|
||||
// the `index.html.tmpl` file. The runtime value of the meta tag is populated by
|
||||
// the app backend, when it templates the final `index.html` file.
|
||||
//
|
||||
// This is needed for additional chunks to use the correct public path, and it
|
||||
// is not possible to set the `__webpack_public_path__` variable outside of the
|
||||
// build itself. The Webpack output also does not read any <base> tags or
|
||||
// similar, this seems to be the only way to dynamically configure the public
|
||||
// path at runtime.
|
||||
const el = document.querySelector('meta[name="backstage-public-path"]');
|
||||
const path = el?.getAttribute('content');
|
||||
if (path) {
|
||||
__webpack_public_path__ = path;
|
||||
}
|
||||
+19
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"version": "0.27.0-next.4",
|
||||
"version": "0.28.0-next.2",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"backstage": {
|
||||
"role": "cli"
|
||||
@@ -53,13 +53,13 @@
|
||||
"@backstage/release-manifests": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@manypkg/get-packages": "^1.1.3",
|
||||
"@module-federation/enhanced": "^0.3.5",
|
||||
"@module-federation/enhanced": "^0.6.0",
|
||||
"@octokit/graphql": "^5.0.0",
|
||||
"@octokit/graphql-schema": "^13.7.0",
|
||||
"@octokit/oauth-app": "^4.2.0",
|
||||
"@octokit/request": "^6.0.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
|
||||
"@rollup/plugin-commonjs": "^25.0.0",
|
||||
"@rollup/plugin-commonjs": "^26.0.0",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-yaml": "^4.0.0",
|
||||
@@ -90,15 +90,14 @@
|
||||
"cross-spawn": "^7.0.3",
|
||||
"css-loader": "^6.5.1",
|
||||
"ctrlc-windows": "^2.1.0",
|
||||
"diff": "^5.0.0",
|
||||
"esbuild": "^0.21.0",
|
||||
"esbuild": "^0.23.0",
|
||||
"esbuild-loader": "^4.0.0",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-formatter-friendly": "^7.0.0",
|
||||
"eslint-plugin-deprecation": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^27.0.0",
|
||||
"eslint-plugin-jest": "^28.0.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
@@ -107,13 +106,15 @@
|
||||
"express": "^4.17.1",
|
||||
"fork-ts-checker-webpack-plugin": "^9.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"git-url-parse": "^14.0.0",
|
||||
"git-url-parse": "^15.0.0",
|
||||
"glob": "^7.1.7",
|
||||
"global-agent": "^3.0.0",
|
||||
"globby": "^11.1.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"inquirer": "^8.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-cli": "^29.7.0",
|
||||
"jest-css-modules": "^2.1.0",
|
||||
"jest-environment-jsdom": "^29.0.2",
|
||||
"jest-runtime": "^29.0.2",
|
||||
@@ -130,6 +131,7 @@
|
||||
"pirates": "^4.0.6",
|
||||
"postcss": "^8.1.0",
|
||||
"process": "^0.11.10",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-dev-utils": "^12.0.0-next.60",
|
||||
"react-refresh": "^0.14.0",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
@@ -146,17 +148,19 @@
|
||||
"swc-loader": "^0.2.3",
|
||||
"tar": "^6.1.12",
|
||||
"terser-webpack-plugin": "^5.1.3",
|
||||
"ts-morph": "^23.0.0",
|
||||
"util": "^0.12.3",
|
||||
"webpack": "^5.70.0",
|
||||
"webpack-dev-server": "^5.0.0",
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"yaml": "^2.0.0",
|
||||
"yargs": "^16.2.0",
|
||||
"yml-loader": "^2.1.0",
|
||||
"yn": "^4.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-common": "^0.25.0",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
@@ -170,7 +174,6 @@
|
||||
"@rspack/dev-server": "^0.7.5",
|
||||
"@rspack/plugin-react-refresh": "^0.7.5",
|
||||
"@types/cross-spawn": "^6.0.2",
|
||||
"@types/diff": "^5.0.0",
|
||||
"@types/ejs": "^3.1.3",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
@@ -186,15 +189,16 @@
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webpack-sources": "^3.2.3",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"del": "^7.0.0",
|
||||
"msw": "^1.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.22.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@rspack/core": "^0.7.5",
|
||||
"@rspack/dev-server": "^0.7.5",
|
||||
"@rspack/plugin-react-refresh": "^0.7.5",
|
||||
@@ -204,6 +208,9 @@
|
||||
"vite-plugin-node-polyfills": "^0.22.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modyfi/vite-plugin-yaml": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import tar, { CreateOptions } from 'tar';
|
||||
import { createDistWorkspace } from '../../lib/packager';
|
||||
import { getEnvironmentParallelism } from '../../lib/parallel';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
|
||||
const BUNDLE_FILE = 'bundle.tar.gz';
|
||||
const SKELETON_FILE = 'skeleton.tar.gz';
|
||||
@@ -42,6 +43,7 @@ export async function buildBackend(options: BuildBackendOptions) {
|
||||
packageJson: pkg,
|
||||
outputs: new Set([Output.cjs]),
|
||||
minify,
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
|
||||
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
import { findRoleFromCommand } from '../../lib/role';
|
||||
import { PackageRoles } from '@backstage/cli-node';
|
||||
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { buildFrontend } from './buildFrontend';
|
||||
import { buildBackend } from './buildBackend';
|
||||
@@ -88,5 +88,6 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
return buildPackage({
|
||||
outputs,
|
||||
minify: Boolean(opts.minify),
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@ export default async (dir: string, packages: string[], options: Options) => {
|
||||
await createDistWorkspace(packages, {
|
||||
targetDir: dir,
|
||||
alwaysYarnPack: options.alwaysYarnPack,
|
||||
enableFeatureDetection: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -114,10 +114,16 @@ export class GithubCreateAppServer {
|
||||
default_events: ['create', 'delete', 'push', 'repository'],
|
||||
default_permissions: {
|
||||
metadata: 'read',
|
||||
...(this.permissions.includes('members') && { members: 'read' }),
|
||||
...(this.permissions.includes('read') && { contents: 'read' }),
|
||||
...(this.permissions.includes('members') && {
|
||||
members: 'read',
|
||||
}),
|
||||
...(this.permissions.includes('read') && {
|
||||
contents: 'read',
|
||||
checks: 'read',
|
||||
}),
|
||||
...(this.permissions.includes('write') && {
|
||||
contents: 'write',
|
||||
checks: 'read',
|
||||
actions: 'write',
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import { paths } from '../../lib/paths';
|
||||
import { GithubCreateAppServer } from './GithubCreateAppServer';
|
||||
import fetch from 'node-fetch';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
|
||||
// This is an experimental command that at this point does not support GitHub Enterprise
|
||||
// due to lacking support for creating apps from manifests.
|
||||
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
|
||||
|
||||
@@ -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]')
|
||||
@@ -50,6 +43,10 @@ export function registerRepoCommand(program: Command) {
|
||||
'--since <ref>',
|
||||
'Only build packages and their dev dependents that changed since the specified ref',
|
||||
)
|
||||
.option(
|
||||
'--minify',
|
||||
'Minify the generated code. Does not apply to app package (app is minified by default).',
|
||||
)
|
||||
.action(lazy(() => import('./repo/build').then(m => m.command)));
|
||||
|
||||
command
|
||||
@@ -64,6 +61,14 @@ export function registerRepoCommand(program: Command) {
|
||||
'--since <ref>',
|
||||
'Only lint packages that changed since the specified ref',
|
||||
)
|
||||
.option(
|
||||
'--successCache',
|
||||
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
|
||||
)
|
||||
.option(
|
||||
'--successCacheDir <path>',
|
||||
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
|
||||
)
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
.action(lazy(() => import('./repo/lint').then(m => m.command)));
|
||||
|
||||
@@ -100,6 +105,14 @@ export function registerRepoCommand(program: Command) {
|
||||
'--since <ref>',
|
||||
'Only test packages that changed since the specified ref',
|
||||
)
|
||||
.option(
|
||||
'--successCache',
|
||||
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
|
||||
)
|
||||
.option(
|
||||
'--successCacheDir <path>',
|
||||
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
|
||||
)
|
||||
.option(
|
||||
'--jest-help',
|
||||
'Show help for Jest CLI options, which are passed through',
|
||||
@@ -159,6 +172,10 @@ export function registerScriptCommand(program: Command) {
|
||||
'eslint-formatter-friendly',
|
||||
)
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
.option(
|
||||
'--max-warnings <number>',
|
||||
'Fail if more than this number of warnings (default: 0)',
|
||||
)
|
||||
.description('Lint a package')
|
||||
.action(lazy(() => import('./lint').then(m => m.default)));
|
||||
|
||||
@@ -261,63 +278,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(
|
||||
@@ -381,7 +341,6 @@ export function registerCommands(program: Command) {
|
||||
registerRepoCommand(program);
|
||||
registerScriptCommand(program);
|
||||
registerMigrateCommand(program);
|
||||
registerOnboardCommand(program);
|
||||
|
||||
program
|
||||
.command('versions:bump')
|
||||
@@ -399,12 +358,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(
|
||||
@@ -420,12 +373,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(
|
||||
@@ -445,14 +392,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;
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export default async (directories: string[], opts: OptionValues) => {
|
||||
directories.length ? directories : ['.'],
|
||||
);
|
||||
|
||||
const maxWarnings = opts.maxWarnings ?? 0;
|
||||
|
||||
const failed =
|
||||
results.some(r => r.errorCount > 0) ||
|
||||
results.reduce((current, next) => current + next.warningCount, 0) >
|
||||
maxWarnings;
|
||||
|
||||
if (opts.fix) {
|
||||
await ESLint.outputFixes(results);
|
||||
}
|
||||
@@ -39,12 +46,14 @@ export default async (directories: string[], opts: OptionValues) => {
|
||||
if (opts.format === 'eslint-formatter-friendly') {
|
||||
process.chdir(paths.targetRoot);
|
||||
}
|
||||
|
||||
const resultText = formatter.format(results);
|
||||
|
||||
// If there is any feedback at all, we treat it as a lint failure. This should be
|
||||
// consistent with our old behavior of passing `--max-warnings=0` when invoking eslint.
|
||||
if (resultText) {
|
||||
console.log(resultText);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { paths } from '../lib/paths';
|
||||
import fs from 'fs-extra';
|
||||
import { publishPreflightCheck } from '../lib/publishing';
|
||||
import { createTypeDistProject } from '../lib/typeDistProject';
|
||||
|
||||
export const pre = async () => {
|
||||
publishPreflightCheck({
|
||||
@@ -28,7 +29,10 @@ export const pre = async () => {
|
||||
packageJson: await fs.readJson(paths.resolveTarget('package.json')),
|
||||
});
|
||||
|
||||
await productionPack({ packageDir: paths.targetDir });
|
||||
await productionPack({
|
||||
packageDir: paths.targetDir,
|
||||
featureDetectionProject: await createTypeDistProject(),
|
||||
});
|
||||
};
|
||||
|
||||
export const post = async () => {
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -20,64 +20,14 @@ import { relative as relativePath } from 'path';
|
||||
import { buildPackages, getOutputsForRole } from '../../lib/builder';
|
||||
import { paths } from '../../lib/paths';
|
||||
import {
|
||||
PackageGraph,
|
||||
BackstagePackage,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
} from '@backstage/cli-node';
|
||||
import { runParallelWorkers } from '../../lib/parallel';
|
||||
import { buildFrontend } from '../build/buildFrontend';
|
||||
import { buildBackend } from '../build/buildBackend';
|
||||
|
||||
function createScriptOptionsParser(anyCmd: Command, commandPath: string[]) {
|
||||
// Regardless of what command instance is passed in we want to find
|
||||
// the root command and resolve the path from there
|
||||
let rootCmd = anyCmd;
|
||||
while (rootCmd.parent) {
|
||||
rootCmd = rootCmd.parent;
|
||||
}
|
||||
|
||||
// Now find the command that was requested
|
||||
let targetCmd = rootCmd as Command | undefined;
|
||||
for (const name of commandPath) {
|
||||
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
|
||||
| Command
|
||||
| undefined;
|
||||
}
|
||||
|
||||
if (!targetCmd) {
|
||||
throw new Error(
|
||||
`Could not find package command '${commandPath.join(' ')}'`,
|
||||
);
|
||||
}
|
||||
const cmd = targetCmd;
|
||||
|
||||
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
|
||||
|
||||
return (scriptStr?: string) => {
|
||||
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const argsStr = scriptStr.slice(expectedScript.length).trim();
|
||||
|
||||
// Can't clone or copy or even use commands as prototype, so we mutate
|
||||
// the necessary members instead, and then reset them once we're done
|
||||
const currentOpts = (cmd as any)._optionValues;
|
||||
const currentStore = (cmd as any)._storeOptionsAsProperties;
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
(cmd as any)._storeOptionsAsProperties = false;
|
||||
(cmd as any)._optionValues = result;
|
||||
|
||||
// Triggers the writing of options to the result object
|
||||
cmd.parseOptions(argsStr.split(' '));
|
||||
|
||||
(cmd as any)._storeOptionsAsProperties = currentOpts;
|
||||
(cmd as any)._optionValues = currentStore;
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
import { createScriptOptionsParser } from './optionsParser';
|
||||
|
||||
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
@@ -136,7 +86,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
packageJson: pkg.packageJson,
|
||||
outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
minify: buildOptions.minify,
|
||||
workspacePackages: packages,
|
||||
minify: opts.minify ?? buildOptions.minify,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -179,7 +130,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
await buildBackend({
|
||||
targetDir: pkg.dir,
|
||||
skipBuildDependencies: true,
|
||||
minify: buildOptions.minify,
|
||||
minify: opts.minify ?? buildOptions.minify,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,11 +15,18 @@
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { OptionValues } from 'commander';
|
||||
import { relative as relativePath } from 'path';
|
||||
import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { Command, OptionValues } from 'commander';
|
||||
import fs from 'fs-extra';
|
||||
import { createHash } from 'crypto';
|
||||
import { relative as relativePath, resolve as resolvePath } from 'path';
|
||||
import {
|
||||
PackageGraph,
|
||||
BackstagePackageJson,
|
||||
Lockfile,
|
||||
} from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { runWorkerQueueThreads } from '../../lib/parallel';
|
||||
import { createScriptOptionsParser } from './optionsParser';
|
||||
|
||||
function depCount(pkg: BackstagePackageJson) {
|
||||
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
|
||||
@@ -29,9 +36,43 @@ function depCount(pkg: BackstagePackageJson) {
|
||||
return deps + devDeps;
|
||||
}
|
||||
|
||||
export async function command(opts: OptionValues): Promise<void> {
|
||||
const CACHE_FILE_NAME = 'lint-cache.json';
|
||||
|
||||
type Cache = string[];
|
||||
|
||||
async function readCache(dir: string): Promise<Cache | undefined> {
|
||||
try {
|
||||
const data = await fs.readJson(resolvePath(dir, CACHE_FILE_NAME));
|
||||
if (!Array.isArray(data)) {
|
||||
return undefined;
|
||||
}
|
||||
if (data.some(x => typeof x !== 'string')) {
|
||||
return undefined;
|
||||
}
|
||||
return data as Cache;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(dir: string, cache: Cache) {
|
||||
await fs.mkdirp(dir);
|
||||
await fs.writeJson(resolvePath(dir, CACHE_FILE_NAME), cache, { spaces: 2 });
|
||||
}
|
||||
|
||||
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
const cacheDir = resolvePath(
|
||||
opts.successCacheDir ?? 'node_modules/.cache/backstage-cli',
|
||||
);
|
||||
const cacheContext = opts.successCache
|
||||
? {
|
||||
cache: await readCache(cacheDir),
|
||||
lockfile: await Lockfile.load(paths.resolveTargetRoot('yarn.lock')),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (opts.since) {
|
||||
const graph = PackageGraph.fromPackages(packages);
|
||||
packages = await graph.listChangedPackages({
|
||||
@@ -54,22 +95,76 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
process.env.FORCE_COLOR = '1';
|
||||
}
|
||||
|
||||
const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']);
|
||||
|
||||
const items = await Promise.all(
|
||||
packages.map(async pkg => {
|
||||
const lintOptions = parseLintScript(pkg.packageJson.scripts?.lint);
|
||||
const base = {
|
||||
fullDir: pkg.dir,
|
||||
relativeDir: relativePath(paths.targetRoot, pkg.dir),
|
||||
lintOptions,
|
||||
parentHash: undefined,
|
||||
};
|
||||
|
||||
if (!cacheContext) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const hash = createHash('sha1');
|
||||
|
||||
hash.update(
|
||||
cacheContext.lockfile.getDependencyTreeHash(pkg.packageJson.name),
|
||||
);
|
||||
hash.update('\0');
|
||||
hash.update(JSON.stringify(lintOptions ?? {}));
|
||||
hash.update('\0');
|
||||
hash.update(process.version); // Node.js version
|
||||
hash.update('\0');
|
||||
hash.update('v1'); // The version of this implementation
|
||||
|
||||
return {
|
||||
...base,
|
||||
parentHash: hash.digest('hex'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsList = await runWorkerQueueThreads({
|
||||
items: packages.map(pkg => ({
|
||||
fullDir: pkg.dir,
|
||||
relativeDir: relativePath(paths.targetRoot, pkg.dir),
|
||||
})),
|
||||
items: items.filter(item => item.lintOptions), // Filter out packages without lint script
|
||||
workerData: {
|
||||
fix: Boolean(opts.fix),
|
||||
format: opts.format as string | undefined,
|
||||
shouldCache: Boolean(cacheContext),
|
||||
successCache: cacheContext?.cache,
|
||||
rootDir: paths.targetRoot,
|
||||
},
|
||||
workerFactory: async ({ fix, format }) => {
|
||||
const { ESLint } = require('eslint');
|
||||
workerFactory: async ({
|
||||
fix,
|
||||
format,
|
||||
shouldCache,
|
||||
successCache,
|
||||
rootDir,
|
||||
}) => {
|
||||
const { ESLint } = require('eslint') as typeof import('eslint');
|
||||
const crypto = require('crypto') as typeof import('crypto');
|
||||
const globby = require('globby') as typeof import('globby');
|
||||
const { readFile } =
|
||||
require('fs/promises') as typeof import('fs/promises');
|
||||
const { relative: workerRelativePath } =
|
||||
require('path') as typeof import('path');
|
||||
|
||||
return async ({
|
||||
fullDir,
|
||||
relativeDir,
|
||||
}): Promise<{ relativeDir: string; resultText: string }> => {
|
||||
lintOptions,
|
||||
parentHash,
|
||||
}): Promise<{
|
||||
relativeDir: string;
|
||||
sha?: string;
|
||||
resultText?: string;
|
||||
failed: boolean;
|
||||
}> => {
|
||||
// Bit of a hack to make file resolutions happen from the correct directory
|
||||
// since some lint rules don't respect the cwd of ESLint
|
||||
process.cwd = () => fullDir;
|
||||
@@ -80,6 +175,39 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
fix,
|
||||
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
|
||||
});
|
||||
|
||||
let sha: string | undefined = undefined;
|
||||
if (shouldCache) {
|
||||
const result = await globby(relativeDir, {
|
||||
gitignore: true,
|
||||
onlyFiles: true,
|
||||
cwd: rootDir,
|
||||
});
|
||||
|
||||
const hash = crypto.createHash('sha1');
|
||||
hash.update(parentHash!);
|
||||
hash.update('\0');
|
||||
|
||||
for (const path of result.sort()) {
|
||||
if (await eslint.isPathIgnored(path)) {
|
||||
continue;
|
||||
}
|
||||
hash.update(workerRelativePath(fullDir, path));
|
||||
hash.update('\0');
|
||||
hash.update(await readFile(path));
|
||||
hash.update('\0');
|
||||
hash.update(
|
||||
JSON.stringify(await eslint.calculateConfigForFile(path)),
|
||||
);
|
||||
hash.update('\0');
|
||||
}
|
||||
sha = await hash.digest('hex');
|
||||
if (successCache?.includes(sha)) {
|
||||
console.log(`Skipped ${relativeDir} due to cache hit`);
|
||||
return { relativeDir, sha, failed: false };
|
||||
}
|
||||
}
|
||||
|
||||
const formatter = await eslint.loadFormatter(format);
|
||||
|
||||
const results = await eslint.lintFiles(['.']);
|
||||
@@ -92,24 +220,51 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
await ESLint.outputFixes(results);
|
||||
}
|
||||
|
||||
const resultText = formatter.format(results);
|
||||
const maxWarnings = lintOptions?.maxWarnings ?? 0;
|
||||
const resultText = formatter.format(results) as string;
|
||||
const failed =
|
||||
results.some(r => r.errorCount > 0) ||
|
||||
results.reduce((current, next) => current + next.warningCount, 0) >
|
||||
maxWarnings;
|
||||
|
||||
return { relativeDir, resultText };
|
||||
return {
|
||||
relativeDir,
|
||||
resultText,
|
||||
failed,
|
||||
sha,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
let failed = false;
|
||||
for (const { relativeDir, resultText } of resultsList) {
|
||||
if (resultText) {
|
||||
console.log();
|
||||
console.log(chalk.red(`Lint failed in ${relativeDir}:`));
|
||||
console.log(resultText.trimStart());
|
||||
const outputSuccessCache = [];
|
||||
|
||||
let failed = false;
|
||||
for (const {
|
||||
relativeDir,
|
||||
resultText,
|
||||
failed: runFailed,
|
||||
sha,
|
||||
} of resultsList) {
|
||||
if (runFailed) {
|
||||
console.log(chalk.red(`Lint failed in ${relativeDir}`));
|
||||
failed = true;
|
||||
|
||||
// When doing repo lint, only list the results if the lint failed to avoid a log
|
||||
// dump of all warnings that might be irrelevant
|
||||
if (resultText) {
|
||||
console.log();
|
||||
console.log(resultText.trimStart());
|
||||
}
|
||||
} else if (sha) {
|
||||
outputSuccessCache.push(sha);
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheContext) {
|
||||
await writeCache(cacheDir, outputSuccessCache);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import chalk from 'chalk';
|
||||
import { ESLint } from 'eslint';
|
||||
import { OptionValues } from 'commander';
|
||||
import { join as joinPath, relative as relativePath } from 'path';
|
||||
import { relative as relativePath } from 'path';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function command(opts: OptionValues) {
|
||||
|
||||
const deprecations = [];
|
||||
for (const [index, pkg] of packages.entries()) {
|
||||
const results = await eslint.lintFiles(joinPath(pkg.dir, 'src'));
|
||||
const results = await eslint.lintFiles(pkg.dir);
|
||||
for (const result of results) {
|
||||
for (const message of result.messages) {
|
||||
if (message.ruleId !== 'deprecation/deprecation') {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2024 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 { Command } from 'commander';
|
||||
|
||||
export function createScriptOptionsParser(
|
||||
anyCmd: Command,
|
||||
commandPath: string[],
|
||||
) {
|
||||
// Regardless of what command instance is passed in we want to find
|
||||
// the root command and resolve the path from there
|
||||
let rootCmd = anyCmd;
|
||||
while (rootCmd.parent) {
|
||||
rootCmd = rootCmd.parent;
|
||||
}
|
||||
|
||||
// Now find the command that was requested
|
||||
let targetCmd = rootCmd as Command | undefined;
|
||||
for (const name of commandPath) {
|
||||
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
|
||||
| Command
|
||||
| undefined;
|
||||
}
|
||||
|
||||
if (!targetCmd) {
|
||||
throw new Error(
|
||||
`Could not find package command '${commandPath.join(' ')}'`,
|
||||
);
|
||||
}
|
||||
const cmd = targetCmd;
|
||||
|
||||
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
|
||||
|
||||
return (scriptStr?: string) => {
|
||||
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const argsStr = scriptStr.slice(expectedScript.length).trim();
|
||||
|
||||
// Can't clone or copy or even use commands as prototype, so we mutate
|
||||
// the necessary members instead, and then reset them once we're done
|
||||
const currentOpts = (cmd as any)._optionValues;
|
||||
const currentStore = (cmd as any)._storeOptionsAsProperties;
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
(cmd as any)._storeOptionsAsProperties = false;
|
||||
(cmd as any)._optionValues = result;
|
||||
|
||||
// Triggers the writing of options to the result object
|
||||
cmd.parseOptions(argsStr.split(' '));
|
||||
|
||||
(cmd as any)._storeOptionsAsProperties = currentOpts;
|
||||
(cmd as any)._optionValues = currentStore;
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -15,10 +15,97 @@
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'fs-extra';
|
||||
import yargs from 'yargs';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'path';
|
||||
import { Command, OptionValues } from 'commander';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { Lockfile, PackageGraph } from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { runCheck } from '../../lib/run';
|
||||
import { runCheck, runPlain } from '../../lib/run';
|
||||
import { isChildPath } from '@backstage/cli-common';
|
||||
|
||||
type JestProject = {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
interface GlobalWithCache extends Global {
|
||||
__backstageCli_jestSuccessCache?: {
|
||||
filterConfigs(
|
||||
projectConfigs: JestProject[],
|
||||
globalConfig: unknown,
|
||||
): Promise<JestProject[]>;
|
||||
reportResults(results: {
|
||||
testResults: Array<{
|
||||
displayName?: { name: string };
|
||||
numFailingTests: number;
|
||||
testFilePath: string;
|
||||
testExecError: {
|
||||
message: string;
|
||||
stack: string;
|
||||
};
|
||||
failureMessage: string;
|
||||
}>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
const CACHE_FILE_NAME = 'test-cache.json';
|
||||
|
||||
type Cache = string[];
|
||||
|
||||
async function readCache(dir: string): Promise<Cache | undefined> {
|
||||
try {
|
||||
const data = await fs.readJson(resolvePath(dir, CACHE_FILE_NAME));
|
||||
if (!Array.isArray(data)) {
|
||||
return undefined;
|
||||
}
|
||||
if (data.some(x => typeof x !== 'string')) {
|
||||
return undefined;
|
||||
}
|
||||
return data as Cache;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(dir: string, cache: Cache) {
|
||||
fs.mkdirpSync(dir);
|
||||
fs.writeJsonSync(resolvePath(dir, CACHE_FILE_NAME), cache, { spaces: 2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Use git to get the HEAD tree hashes of each package in the project.
|
||||
*/
|
||||
async function readPackageTreeHashes(graph: PackageGraph) {
|
||||
const pkgs = Array.from(graph.values());
|
||||
const output = await runPlain(
|
||||
'git',
|
||||
'ls-tree',
|
||||
'--object-only',
|
||||
'HEAD',
|
||||
'--',
|
||||
...pkgs.map(pkg => relativePath(paths.targetRoot, pkg.dir)),
|
||||
);
|
||||
|
||||
const treeShaList = output.trim().split(/\r?\n/);
|
||||
if (treeShaList.length !== pkgs.length) {
|
||||
throw new Error(
|
||||
`Error listing project git tree hashes, output length does not equal input length`,
|
||||
);
|
||||
}
|
||||
|
||||
const map = new Map(
|
||||
pkgs.map((pkg, i) => [pkg.packageJson.name, treeShaList[i]]),
|
||||
);
|
||||
return (pkgName: string) => {
|
||||
const sha = map.get(pkgName);
|
||||
if (!sha) {
|
||||
throw new Error(`Tree sha not found for ${pkgName}`);
|
||||
}
|
||||
return sha;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFlagFinder(args: string[]) {
|
||||
const flags = new Set<string>();
|
||||
@@ -46,7 +133,7 @@ export function createFlagFinder(args: string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function removeOptionArg(args: string[], option: string) {
|
||||
function removeOptionArg(args: string[], option: string, size: number = 2) {
|
||||
let changed = false;
|
||||
do {
|
||||
changed = false;
|
||||
@@ -54,7 +141,7 @@ function removeOptionArg(args: string[], option: string) {
|
||||
const index = args.indexOf(option);
|
||||
if (index >= 0) {
|
||||
changed = true;
|
||||
args.splice(index, 2);
|
||||
args.splice(index, size);
|
||||
}
|
||||
const indexEq = args.findIndex(arg => arg.startsWith(`${option}=`));
|
||||
if (indexEq >= 0) {
|
||||
@@ -120,9 +207,18 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
removeOptionArg(args, '--since');
|
||||
}
|
||||
|
||||
if (opts.since && !hasFlags('--selectProjects')) {
|
||||
let packageGraph: PackageGraph | undefined;
|
||||
async function getPackageGraph() {
|
||||
if (packageGraph) {
|
||||
return packageGraph;
|
||||
}
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
const graph = PackageGraph.fromPackages(packages);
|
||||
packageGraph = PackageGraph.fromPackages(packages);
|
||||
return packageGraph;
|
||||
}
|
||||
|
||||
if (opts.since && !hasFlags('--selectProjects')) {
|
||||
const graph = await getPackageGraph();
|
||||
const changedPackages = await graph.listChangedPackages({
|
||||
ref: opts.since,
|
||||
analyzeLockfile: true,
|
||||
@@ -163,5 +259,135 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
(process.stdout as any)._handle.setBlocking(true);
|
||||
}
|
||||
|
||||
await require('jest').run(args);
|
||||
const jestCli = require('jest-cli');
|
||||
|
||||
// This code path is enabled by the --successCache flag, which is specific to
|
||||
// the `repo test` command in the Backstage CLI.
|
||||
if (opts.successCache) {
|
||||
removeOptionArg(args, '--successCache', 1);
|
||||
removeOptionArg(args, '--successCacheDir');
|
||||
|
||||
const cacheDir = resolvePath(
|
||||
opts.successCacheDir ?? 'node_modules/.cache/backstage-cli',
|
||||
);
|
||||
|
||||
// Parse the args to ensure that no file filters are provided, in which case we refuse to run
|
||||
const { _: parsedArgs } = await yargs(args).options(jestCli.yargsOptions)
|
||||
.argv;
|
||||
if (parsedArgs.length > 0) {
|
||||
throw new Error(
|
||||
`The --successCache flag can not be combined with the following arguments: ${parsedArgs.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
// Likewise, it's not possible to combine sharding and the success cache
|
||||
if (args.includes('--shard')) {
|
||||
throw new Error(
|
||||
`The --successCache flag can not be combined with the --shard flag`,
|
||||
);
|
||||
}
|
||||
|
||||
const graph = await getPackageGraph();
|
||||
|
||||
// Shared state for the bridge
|
||||
const projectHashes = new Map<string, string>();
|
||||
const outputSuccessCache = new Array<string>();
|
||||
|
||||
// Set up a bridge with the @backstage/cli/config/jest configuration file. These methods
|
||||
// are picked up by the config script itself, as well as the custom result processor.
|
||||
const globalWithCache = global as GlobalWithCache;
|
||||
globalWithCache.__backstageCli_jestSuccessCache = {
|
||||
// This is called by `config/jest.js` after the project configs have been gathered
|
||||
async filterConfigs(projectConfigs, globalRootConfig) {
|
||||
const cache = await readCache(cacheDir);
|
||||
const lockfile = await Lockfile.load(
|
||||
paths.resolveTargetRoot('yarn.lock'),
|
||||
);
|
||||
const getPackageTreeHash = await readPackageTreeHashes(graph);
|
||||
|
||||
// Base hash shared by all projects
|
||||
const baseHash = crypto.createHash('sha1');
|
||||
baseHash.update('v1'); // The version of this implementation
|
||||
baseHash.update('\0');
|
||||
baseHash.update(process.version); // Node.js version
|
||||
baseHash.update('\0');
|
||||
baseHash.update(JSON.stringify(globalRootConfig)); // Variable global jest config
|
||||
const baseSha = baseHash.digest('hex');
|
||||
|
||||
return projectConfigs.filter(project => {
|
||||
const packageName = project.displayName;
|
||||
const pkg = graph.get(packageName);
|
||||
if (!pkg) {
|
||||
throw new Error(
|
||||
`Package ${packageName} not found in package graph`,
|
||||
);
|
||||
}
|
||||
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
hash.update(baseSha); // Global base hash
|
||||
|
||||
const packageTreeSha = getPackageTreeHash(packageName);
|
||||
hash.update(packageTreeSha); // Hash for target package contents
|
||||
|
||||
for (const [depName, depPkg] of pkg.allLocalDependencies) {
|
||||
const depHash = getPackageTreeHash(depPkg.name);
|
||||
hash.update(`${depName}:${depHash}`); // Hash for each local monorepo dependency contents
|
||||
}
|
||||
|
||||
// The project ID is a hash of the transform configuration, which helps
|
||||
// us bust the cache when any changes are made to the transform implementation.
|
||||
hash.update(JSON.stringify(project));
|
||||
hash.update(lockfile.getDependencyTreeHash(packageName));
|
||||
|
||||
const sha = hash.digest('hex');
|
||||
|
||||
projectHashes.set(packageName, sha);
|
||||
|
||||
if (cache?.includes(sha)) {
|
||||
console.log(`Skipped ${packageName} due to cache hit`);
|
||||
outputSuccessCache.push(sha);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return project;
|
||||
});
|
||||
},
|
||||
// This is called by `config/jestCacheResultProcess.cjs` after all tests have run
|
||||
async reportResults(results) {
|
||||
const successful = new Set<string>();
|
||||
const failed = new Set<string>();
|
||||
|
||||
for (const testResult of results.testResults) {
|
||||
for (const [pkgName, pkg] of graph) {
|
||||
if (isChildPath(pkg.dir, testResult.testFilePath)) {
|
||||
if (
|
||||
testResult.testExecError ||
|
||||
testResult.failureMessage ||
|
||||
testResult.numFailingTests > 0
|
||||
) {
|
||||
failed.add(pkgName);
|
||||
successful.delete(pkgName);
|
||||
} else if (!failed.has(pkgName)) {
|
||||
successful.add(pkgName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkgName of successful) {
|
||||
const sha = projectHashes.get(pkgName);
|
||||
if (sha) {
|
||||
outputSuccessCache.push(sha);
|
||||
}
|
||||
}
|
||||
|
||||
await writeCache(cacheDir, outputSuccessCache);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await jestCli.run(args);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function startBackend(options: StartBackendOptions) {
|
||||
|
||||
await waitForExit();
|
||||
} else {
|
||||
console.warn(
|
||||
'LEGACY_BACKEND_START is deprecated and will be removed in a future release',
|
||||
);
|
||||
|
||||
const waitForExit = await cleanDistAndServeBackend({
|
||||
entry: 'src/index',
|
||||
checksEnabled: options.checksEnabled,
|
||||
@@ -55,15 +59,9 @@ export async function startBackendPlugin(options: StartBackendOptions) {
|
||||
const hasDevIndexEntry = await fs.pathExists(
|
||||
paths.resolveTarget('dev', 'index.ts'),
|
||||
);
|
||||
const hasSrcIndexEntry = await fs.pathExists(
|
||||
paths.resolveTarget('src', 'run.ts'),
|
||||
);
|
||||
|
||||
if (!hasDevIndexEntry && !hasSrcIndexEntry) {
|
||||
if (!hasDevIndexEntry) {
|
||||
console.warn(
|
||||
hasSrcIndexEntry
|
||||
? `The 'dev' directory is missing. The plugin might not be updated for the new backend system. To run, use "LEGACY_BACKEND_START=1 yarn start".`
|
||||
: `The 'dev' directory is missing. Please create a proper dev/index.ts in order to start the plugin.`,
|
||||
`The 'dev' directory is missing. Please create a proper dev/index.ts in order to start the plugin.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -85,6 +83,9 @@ export async function startBackendPlugin(options: StartBackendOptions) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn(
|
||||
'LEGACY_BACKEND_START is deprecated and will be removed in a future release',
|
||||
);
|
||||
|
||||
const waitForExit = await cleanDistAndServeBackend({
|
||||
entry: 'src/run',
|
||||
|
||||
@@ -25,6 +25,8 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import semver from 'semver';
|
||||
import { OptionValues } from 'commander';
|
||||
import yaml from 'yaml';
|
||||
import z from 'zod';
|
||||
import { isError, NotFoundError } from '@backstage/errors';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { run } from '../../lib/run';
|
||||
@@ -76,6 +78,8 @@ type PkgVersionInfo = {
|
||||
export default async (opts: OptionValues) => {
|
||||
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
|
||||
const lockfile = await Lockfile.load(lockfilePath);
|
||||
const hasYarnPlugin = await getHasYarnPlugin();
|
||||
|
||||
let pattern = opts.pattern;
|
||||
|
||||
if (!pattern) {
|
||||
@@ -177,12 +181,34 @@ export default async (opts: OptionValues) => {
|
||||
for (const depType of DEP_TYPES) {
|
||||
if (depType in pkgJson && dep.name in pkgJson[depType]) {
|
||||
const oldRange = pkgJson[depType][dep.name];
|
||||
pkgJson[depType][dep.name] = dep.range;
|
||||
|
||||
// backstage:^ are written to the lockfile as
|
||||
// backstage:<backstage-version>, so that updates to
|
||||
// backstage.json can be detected during yarn install. In order to
|
||||
// locate the corresponding lockfile entry for "backstage:^"
|
||||
// versions, we need to perform the same transformation.
|
||||
const oldLockfileRange = await asLockfileVersion(oldRange);
|
||||
|
||||
const useBackstageRange =
|
||||
hasYarnPlugin &&
|
||||
// Only use backstage:^ versions if the package is present in
|
||||
// the manifest for the release we're bumping to.
|
||||
releaseManifest.packages.find(
|
||||
({ name: manifestPackageName }) =>
|
||||
dep.name === manifestPackageName,
|
||||
) &&
|
||||
// Don't use backstage:^ versions for peerDependencies; they only
|
||||
// support npm and workspace: versions.
|
||||
depType !== 'peerDependencies';
|
||||
|
||||
const newRange = useBackstageRange ? 'backstage:^' : dep.range;
|
||||
|
||||
pkgJson[depType][dep.name] = newRange;
|
||||
|
||||
// Check if the update was at least a pre-v1 minor or post-v1 major release
|
||||
const lockfileEntry = lockfile
|
||||
.get(dep.name)
|
||||
?.find(entry => entry.range === oldRange);
|
||||
?.find(entry => entry.range === oldLockfileRange);
|
||||
if (lockfileEntry) {
|
||||
const from = lockfileEntry.version;
|
||||
const to = dep.target;
|
||||
@@ -266,6 +292,21 @@ export default async (opts: OptionValues) => {
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (hasYarnPlugin) {
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`${chalk.bold(
|
||||
'NOTE',
|
||||
)}: this bump used backstage:^ versions in package.json files, since the Backstage ` +
|
||||
`yarn plugin was detected in the repository. To migrate back to explicit npm versions, ` +
|
||||
`remove the plugin by running "yarn plugin remove @yarnpkg/plugin-backstage", then ` +
|
||||
`repeat this command.`,
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(chalk.green('Version bump complete!'));
|
||||
}
|
||||
|
||||
@@ -354,16 +395,23 @@ export function createVersionFinder(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function bumpBackstageJsonVersion(version: string) {
|
||||
const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON);
|
||||
const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => {
|
||||
function getBackstageJsonPath() {
|
||||
return paths.resolveTargetRoot(BACKSTAGE_JSON);
|
||||
}
|
||||
|
||||
async function getBackstageJson() {
|
||||
const backstageJsonPath = getBackstageJsonPath();
|
||||
return fs.readJSON(backstageJsonPath).catch(e => {
|
||||
if (e.code === 'ENOENT') {
|
||||
// gracefully continue in case the file doesn't exist
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
export async function bumpBackstageJsonVersion(version: string) {
|
||||
const backstageJson = await getBackstageJson();
|
||||
const prevVersion = backstageJson?.version;
|
||||
|
||||
if (prevVersion === version) {
|
||||
@@ -394,7 +442,7 @@ export async function bumpBackstageJsonVersion(version: string) {
|
||||
}
|
||||
|
||||
await fs.writeJson(
|
||||
backstageJsonPath,
|
||||
getBackstageJsonPath(),
|
||||
{ ...backstageJson, version },
|
||||
{
|
||||
spaces: 2,
|
||||
@@ -403,6 +451,53 @@ export async function bumpBackstageJsonVersion(version: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function asLockfileVersion(version: string) {
|
||||
if (version === 'backstage:^') {
|
||||
return `backstage:${(await getBackstageJson())?.version}`;
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
const yarnRcSchema = z.object({
|
||||
plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function getHasYarnPlugin() {
|
||||
const yarnRcPath = paths.resolveTargetRoot('.yarnrc.yml');
|
||||
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
|
||||
if (e.code === 'ENOENT') {
|
||||
// gracefully continue in case the file doesn't exist
|
||||
return '';
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (!yarnRcContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parseResult = yarnRcSchema.safeParse(yaml.parse(yarnRcContent));
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected content in .yarnrc.yml: ${parseResult.error.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const yarnRc = parseResult.data;
|
||||
|
||||
return yarnRc.plugins?.some(
|
||||
plugin => plugin.path === '.yarn/plugins/@yarnpkg/plugin-backstage.cjs',
|
||||
);
|
||||
}
|
||||
|
||||
export async function runYarnInstall() {
|
||||
const spinner = ora({
|
||||
prefixText: `Running ${chalk.blue('yarn install')} to install new versions`,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2024 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 { PackageRole } from '@backstage/cli-node';
|
||||
import { Project } from 'ts-morph';
|
||||
import { BackstagePackageFeatureType } from '../typeDistProject';
|
||||
|
||||
const mockEntryPoint = 'dist/index.d.ts';
|
||||
|
||||
type CreateFeatureEnvironmentOptions = {
|
||||
$$type?: BackstagePackageFeatureType;
|
||||
format?:
|
||||
| 'DefaultExportAssignment'
|
||||
| 'DefaultExportFromFile'
|
||||
| 'DefaultExportFromFileAsDefault'
|
||||
| 'DefaultExportFromFileWithSibling';
|
||||
role?: PackageRole;
|
||||
};
|
||||
|
||||
type FeatureEnvironment = {
|
||||
project: Project;
|
||||
role: PackageRole;
|
||||
dir: string;
|
||||
entryPoint: string;
|
||||
};
|
||||
|
||||
type File = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const createTestType = ($$type: BackstagePackageFeatureType): File[] => [
|
||||
{
|
||||
path: './dist/createTestType.d.ts',
|
||||
content: `
|
||||
export interface TestType {
|
||||
readonly $$type: '${$$type}';
|
||||
};
|
||||
|
||||
export function createTestType(): TestType {
|
||||
return {
|
||||
$$type: '${$$type}',
|
||||
};
|
||||
};
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportAssignment = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFile = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { default } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFileAsDefault = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { test as default } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
export declare const test: import("./createTestType").TestType;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFileWithSibling = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { default, test } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
import { createTestType } from './createTestType';
|
||||
|
||||
export declare const test: import("./createTestType").TestType;
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const formatToFiles = {
|
||||
DefaultExportAssignment: createMockDefaultExportAssignment,
|
||||
DefaultExportFromFile: createMockDefaultExportFromFile,
|
||||
DefaultExportFromFileAsDefault: createMockDefaultExportFromFileAsDefault,
|
||||
DefaultExportFromFileWithSibling: createMockDefaultExportFromFileWithSibling,
|
||||
};
|
||||
|
||||
export default function createFeatureEnvironment(
|
||||
options?: CreateFeatureEnvironmentOptions,
|
||||
): FeatureEnvironment {
|
||||
const {
|
||||
$$type = '@backstage/BackendFeature',
|
||||
format = 'DefaultExportAssignment',
|
||||
role = 'backend-plugin',
|
||||
} = options ?? {};
|
||||
|
||||
const project = new Project();
|
||||
const files = [...createTestType($$type), ...formatToFiles[format]()];
|
||||
|
||||
for (const file of files) {
|
||||
project.createSourceFile(file.path, file.content);
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
role,
|
||||
dir: project.getFileSystem().getCurrentDirectory(),
|
||||
entryPoint: mockEntryPoint,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ describe('makeRollupConfigs', () => {
|
||||
version: '0.0.0',
|
||||
main: './src/index.ts',
|
||||
},
|
||||
workspacePackages: [],
|
||||
});
|
||||
const external = config.external as Exclude<
|
||||
ExternalOption,
|
||||
|
||||
@@ -53,6 +53,21 @@ function isFileImport(source: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildInternalImportPattern(options: BuildOptions) {
|
||||
const inlinedPackages = options.workspacePackages.filter(
|
||||
pkg => pkg.packageJson.backstage?.inline,
|
||||
);
|
||||
for (const { packageJson } of inlinedPackages) {
|
||||
if (!packageJson.private) {
|
||||
throw new Error(
|
||||
`Inlined package ${packageJson.name} must be marked as private`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = inlinedPackages.map(pkg => pkg.packageJson.name);
|
||||
return new RegExp(`^(?:${names.join('|')})(?:$|/)`);
|
||||
}
|
||||
|
||||
export async function makeRollupConfigs(
|
||||
options: BuildOptions,
|
||||
): Promise<RollupOptions[]> {
|
||||
@@ -83,6 +98,19 @@ export async function makeRollupConfigs(
|
||||
SCRIPT_EXTS.includes(e.ext),
|
||||
);
|
||||
|
||||
const internalImportPattern = buildInternalImportPattern(options);
|
||||
const external = (
|
||||
source: string,
|
||||
importer: string | undefined,
|
||||
isResolved: boolean,
|
||||
) =>
|
||||
Boolean(
|
||||
importer &&
|
||||
!isResolved &&
|
||||
!internalImportPattern.test(source) &&
|
||||
!isFileImport(source),
|
||||
);
|
||||
|
||||
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
|
||||
const output = new Array<OutputOptions>();
|
||||
const mainFields = ['module', 'main'];
|
||||
@@ -95,6 +123,8 @@ export async function makeRollupConfigs(
|
||||
format: 'commonjs',
|
||||
interop: 'compat',
|
||||
sourcemap: true,
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: `${targetDir}/src`,
|
||||
exports: 'named',
|
||||
});
|
||||
}
|
||||
@@ -118,10 +148,10 @@ export async function makeRollupConfigs(
|
||||
),
|
||||
output,
|
||||
onwarn,
|
||||
makeAbsoluteExternalsRelative: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
// All module imports are always marked as external
|
||||
external: (source, importer, isResolved) =>
|
||||
Boolean(importer && !isResolved && !isFileImport(source)),
|
||||
external,
|
||||
plugins: [
|
||||
resolve({ mainFields }),
|
||||
commonjs({
|
||||
@@ -190,18 +220,11 @@ export async function makeRollupConfigs(
|
||||
chunkFileNames: `types/[name]-[hash].d.ts`,
|
||||
format: 'es',
|
||||
},
|
||||
external: [
|
||||
/\.css$/,
|
||||
/\.scss$/,
|
||||
/\.sass$/,
|
||||
/\.svg$/,
|
||||
/\.eot$/,
|
||||
/\.woff$/,
|
||||
/\.woff2$/,
|
||||
/\.ttf$/,
|
||||
],
|
||||
external: (source, importer, isResolved) =>
|
||||
/\.css|scss|sass|svg|eot|woff|woff2|ttf$/.test(source) ||
|
||||
external(source, importer, isResolved),
|
||||
onwarn,
|
||||
plugins: [dts()],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { BackstagePackage, BackstagePackageJson } from '@backstage/cli-node';
|
||||
|
||||
export enum Output {
|
||||
esm,
|
||||
@@ -28,4 +28,5 @@ export type BuildOptions = {
|
||||
packageJson?: BackstagePackageJson;
|
||||
outputs: Set<Output>;
|
||||
minify?: boolean;
|
||||
workspacePackages: BackstagePackage[];
|
||||
};
|
||||
|
||||
@@ -207,6 +207,19 @@ export async function createConfig(
|
||||
},
|
||||
}),
|
||||
);
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
meta: {
|
||||
'backstage-app-mode': options?.appMode ?? 'public',
|
||||
// This is added to be written in the later step, and finally read by the extra entry point
|
||||
'backstage-public-path': '<%= publicPath %>/',
|
||||
},
|
||||
minify: false,
|
||||
publicPath: '<%= publicPath %>',
|
||||
filename: 'index.html.tmpl',
|
||||
template: `raw-loader!${paths.targetHtml}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.moduleFederation) {
|
||||
@@ -359,6 +372,13 @@ export async function createConfig(
|
||||
return {
|
||||
mode,
|
||||
profile: false,
|
||||
...(isDev
|
||||
? {
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
optimization,
|
||||
bail: false,
|
||||
performance: {
|
||||
@@ -366,7 +386,11 @@ export async function createConfig(
|
||||
},
|
||||
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
|
||||
context: paths.targetPath,
|
||||
entry: [...(options.additionalEntryPoints ?? []), paths.targetEntry],
|
||||
entry: [
|
||||
require.resolve('@backstage/cli/config/webpack-public-path'),
|
||||
...(options.additionalEntryPoints ?? []),
|
||||
paths.targetEntry,
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json', '.wasm'],
|
||||
mainFields: ['browser', 'module', 'main'],
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { paths } from '../paths';
|
||||
|
||||
export function hasReactDomClient() {
|
||||
try {
|
||||
require.resolve('react-dom/client');
|
||||
require.resolve('react-dom/client', {
|
||||
paths: [paths.targetDir],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -54,6 +54,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
|
||||
const { name } = await fs.readJson(libPaths.resolveTarget('package.json'));
|
||||
|
||||
let webpackServer: WebpackDevServer | undefined = undefined;
|
||||
// @ts-ignore
|
||||
let viteServer: import('vite').ViteDevServer | undefined = undefined;
|
||||
|
||||
let latestFrontendAppConfigs: AppConfig[] = [];
|
||||
@@ -129,24 +130,65 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
|
||||
});
|
||||
|
||||
if (process.env.EXPERIMENTAL_VITE) {
|
||||
const vite = require('vite');
|
||||
const { default: viteReact } = require('@vitejs/plugin-react');
|
||||
const {
|
||||
nodePolyfills: viteNodePolyfills,
|
||||
} = require('vite-plugin-node-polyfills');
|
||||
const { createHtmlPlugin: viteHtml } = require('vite-plugin-html');
|
||||
const vite = require('vite') as typeof import('vite');
|
||||
const { default: viteReact } =
|
||||
require('@vitejs/plugin-react') as typeof import('@vitejs/plugin-react');
|
||||
const { default: viteYaml } =
|
||||
require('@modyfi/vite-plugin-yaml') as typeof import('@modyfi/vite-plugin-yaml');
|
||||
const { nodePolyfills: viteNodePolyfills } =
|
||||
require('vite-plugin-node-polyfills') as typeof import('vite-plugin-node-polyfills');
|
||||
const { createHtmlPlugin: viteHtml } =
|
||||
require('vite-plugin-html') as typeof import('vite-plugin-html');
|
||||
|
||||
viteServer = await vite.createServer({
|
||||
define: {
|
||||
global: 'window',
|
||||
'process.argv': JSON.stringify(process.argv),
|
||||
'process.env.APP_CONFIG': JSON.stringify(cliConfig.frontendAppConfigs),
|
||||
// This allows for conditional imports of react-dom/client, since there's no way
|
||||
// to check for presence of it in source code without module resolution errors.
|
||||
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()),
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'custom-define',
|
||||
setup(build) {
|
||||
const define = (build.initialOptions.define ||= {});
|
||||
define['process.env.HAS_REACT_DOM_CLIENT'] = JSON.stringify(
|
||||
hasReactDomClient(),
|
||||
);
|
||||
define['process.env.NODE_ENV'] = JSON.stringify('development');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
viteReact(),
|
||||
viteNodePolyfills(),
|
||||
viteNodePolyfills({
|
||||
include: [
|
||||
'buffer',
|
||||
'events',
|
||||
'fs',
|
||||
'http',
|
||||
'https',
|
||||
'os',
|
||||
'path',
|
||||
'process',
|
||||
'querystring',
|
||||
'stream',
|
||||
'url',
|
||||
'util',
|
||||
'zlib',
|
||||
],
|
||||
globals: {
|
||||
global: true,
|
||||
Buffer: true,
|
||||
process: true,
|
||||
},
|
||||
}),
|
||||
viteYaml(),
|
||||
viteHtml({
|
||||
entry: paths.targetEntry,
|
||||
// todo(blam): we should look at contributing to thPe plugin here
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ModuleOptions, WebpackPluginInstance } from 'webpack';
|
||||
import { RuleSetRule, WebpackPluginInstance } from 'webpack';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { svgrTemplate } from '../svgrTemplate';
|
||||
|
||||
type Transforms = {
|
||||
loaders: ModuleOptions['rules'];
|
||||
loaders: RuleSetRule[];
|
||||
plugins: WebpackPluginInstance[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -67,6 +67,11 @@ export function readEntryPoints(pkg: BackstagePackageJson): Array<EntryPoint> {
|
||||
);
|
||||
}
|
||||
|
||||
// Setting the EXPERIMENTAL_TRIM_NEXT_ENTRY flag will remove any `./next` entry points
|
||||
if (process.env.EXPERIMENTAL_TRIM_NEXT_ENTRY && mount === './next') {
|
||||
continue;
|
||||
}
|
||||
|
||||
entryPoints.push(parseEntryPoint(mount, path));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ describe('backendModule factory', () => {
|
||||
`availability plugins${sep}test-backend-module-tester-two`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('backendPlugin factory', () => {
|
||||
`availability plugins${sep}test-backend`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating index.ts.hbs',
|
||||
'templating package.json.hbs',
|
||||
|
||||
@@ -79,12 +79,12 @@ export const backendPlugin = createFactory<Options>({
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'plugin',
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${id}`, options.owner);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('frontendPlugin factory', () => {
|
||||
`availability plugins${sep}test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.tsx.hbs',
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('nodeLibraryPackage factory', () => {
|
||||
`availability ${joinPath('packages', expectedNodeLibraryPackageName)}`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('pluginCommon factory', () => {
|
||||
`availability plugins${sep}test-common`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('pluginNode factory', () => {
|
||||
`availability plugins${sep}test-node`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('pluginWeb factory', () => {
|
||||
`availability plugins${sep}test-react`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
import { scaffolderModule } from './scaffolderModule';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const backendIndexTsContent = `
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.start();
|
||||
`;
|
||||
|
||||
describe('scaffolderModule factory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
@@ -41,6 +49,14 @@ describe('scaffolderModule factory', () => {
|
||||
|
||||
it('should create a scaffolder backend module package', async () => {
|
||||
mockDir.setContent({
|
||||
packages: {
|
||||
backend: {
|
||||
'package.json': JSON.stringify({}),
|
||||
src: {
|
||||
'index.ts': backendIndexTsContent,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {},
|
||||
});
|
||||
|
||||
@@ -73,7 +89,7 @@ describe('scaffolderModule factory', () => {
|
||||
`availability plugins${sep}scaffolder-backend-module-test`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
@@ -84,8 +100,29 @@ describe('scaffolderModule factory', () => {
|
||||
'copying module.ts',
|
||||
'Installing:',
|
||||
`moving plugins${sep}scaffolder-backend-module-test`,
|
||||
'backend adding dependency',
|
||||
'backend adding module',
|
||||
]);
|
||||
|
||||
await expect(
|
||||
fs.readFile(mockDir.resolve('packages/backend/src/index.ts'), 'utf8'),
|
||||
).resolves.toBe(`
|
||||
import { createBackend } from '@backstage/backend-defaults';
|
||||
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('backstage-plugin-scaffolder-backend-module-test'));
|
||||
backend.start();
|
||||
`);
|
||||
|
||||
await expect(
|
||||
fs.readJson(mockDir.resolve('packages/backend/package.json')),
|
||||
).resolves.toEqual({
|
||||
dependencies: {
|
||||
'backstage-plugin-scaffolder-backend-module-test': '^1.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readJson(
|
||||
mockDir.resolve('plugins/scaffolder-backend-module-test/package.json'),
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import { paths } from '../../paths';
|
||||
import { addCodeownersEntry, getCodeownersFilePath } from '../../codeowners';
|
||||
import { CreateContext, createFactory } from '../types';
|
||||
import { Task } from '../../tasks';
|
||||
import { addPackageDependency, addToBackend, Task } from '../../tasks';
|
||||
import { ownerPrompt } from './common/prompts';
|
||||
import { executePluginPackageTemplate } from './common/tasks';
|
||||
import { resolvePackageName } from './common/util';
|
||||
@@ -82,6 +83,23 @@ export const scaffolderModule = createFactory<Options>({
|
||||
},
|
||||
});
|
||||
|
||||
if (await fs.pathExists(paths.resolveTargetRoot('packages/backend'))) {
|
||||
await Task.forItem('backend', 'adding dependency', async () => {
|
||||
await addPackageDependency(
|
||||
paths.resolveTargetRoot('packages/backend/package.json'),
|
||||
{
|
||||
dependencies: {
|
||||
[name]: `^${ctx.defaultVersion}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await addToBackend(name, {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
if (options.owner) {
|
||||
await addCodeownersEntry(`/plugins/${slug}`, options.owner);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('webLibraryPackage factory', () => {
|
||||
`availability ${joinPath('packages', expectedwebLibraryPackageName)}`,
|
||||
'creating temp dir',
|
||||
'Executing Template:',
|
||||
'copying .eslintrc.js',
|
||||
'templating .eslintrc.js.hbs',
|
||||
'templating README.md.hbs',
|
||||
'templating package.json.hbs',
|
||||
'templating index.ts.hbs',
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
PackageGraphNode,
|
||||
} from '@backstage/cli-node';
|
||||
import { runParallelWorkers } from '../parallel';
|
||||
import { createTypeDistProject } from '../typeDistProject';
|
||||
|
||||
// These packages aren't safe to pack in parallel since the CLI depends on them
|
||||
const UNSAFE_PACKAGES = [
|
||||
@@ -104,6 +105,12 @@ type Options = {
|
||||
*/
|
||||
alwaysYarnPack?: boolean;
|
||||
|
||||
/**
|
||||
* If set to true, the TypeScript feature detection will be enabled, which
|
||||
* annotates the package exports field with the `backstage` export type.
|
||||
*/
|
||||
enableFeatureDetection?: boolean;
|
||||
|
||||
/**
|
||||
* If set to true, the generated code will be minified.
|
||||
*/
|
||||
@@ -211,6 +218,7 @@ export async function createDistWorkspace(
|
||||
outputs: outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
minify: options.minify,
|
||||
workspacePackages: packages,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -235,6 +243,7 @@ export async function createDistWorkspace(
|
||||
targetDir,
|
||||
targets,
|
||||
Boolean(options.alwaysYarnPack),
|
||||
Boolean(options.enableFeatureDetection),
|
||||
);
|
||||
|
||||
const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json'];
|
||||
@@ -278,6 +287,7 @@ async function moveToDistWorkspace(
|
||||
workspaceDir: string,
|
||||
localPackages: PackageGraphNode[],
|
||||
alwaysYarnPack: boolean,
|
||||
enableFeatureDetection: boolean,
|
||||
): Promise<void> {
|
||||
const [fastPackPackages, slowPackPackages] = partition(
|
||||
localPackages,
|
||||
@@ -286,6 +296,11 @@ async function moveToDistWorkspace(
|
||||
FAST_PACK_SCRIPTS.includes(pkg.packageJson.scripts?.prepack),
|
||||
);
|
||||
|
||||
const featureDetectionProject =
|
||||
fastPackPackages.length > 0 && enableFeatureDetection
|
||||
? await createTypeDistProject()
|
||||
: undefined;
|
||||
|
||||
// New an improved flow where we avoid calling `yarn pack`
|
||||
await Promise.all(
|
||||
fastPackPackages.map(async target => {
|
||||
@@ -296,6 +311,7 @@ async function moveToDistWorkspace(
|
||||
await productionPack({
|
||||
packageDir: target.dir,
|
||||
targetDir: absoluteOutputPath,
|
||||
featureDetectionProject,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,8 @@ import npmPackList from 'npm-packlist';
|
||||
import { resolve as resolvePath, posix as posixPath } from 'path';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
import { getEntryPointDefaultFeatureType } from '../typeDistProject';
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
const PKG_PATH = 'package.json';
|
||||
const PKG_BACKUP_PATH = 'package.json-prepack';
|
||||
@@ -29,6 +31,10 @@ const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
||||
interface ProductionPackOptions {
|
||||
packageDir: string;
|
||||
targetDir?: string;
|
||||
/**
|
||||
* Enables package feature detection using this TS-morph project.
|
||||
*/
|
||||
featureDetectionProject?: Project;
|
||||
}
|
||||
|
||||
export async function productionPack(options: ProductionPackOptions) {
|
||||
@@ -46,6 +52,7 @@ export async function productionPack(options: ProductionPackOptions) {
|
||||
const writeCompatibilityEntryPoints = await prepareExportsEntryPoints(
|
||||
pkg,
|
||||
packageDir,
|
||||
options.featureDetectionProject,
|
||||
);
|
||||
|
||||
// TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior
|
||||
@@ -134,6 +141,7 @@ const EXPORT_MAP = {
|
||||
async function prepareExportsEntryPoints(
|
||||
pkg: BackstagePackageJson,
|
||||
packageDir: string,
|
||||
featureDetectionProject?: Project,
|
||||
) {
|
||||
const distPath = resolvePath(packageDir, 'dist');
|
||||
if (!(await fs.pathExists(distPath))) {
|
||||
@@ -147,20 +155,43 @@ async function prepareExportsEntryPoints(
|
||||
>();
|
||||
|
||||
const entryPoints = readEntryPoints(pkg);
|
||||
|
||||
for (const entryPoint of entryPoints) {
|
||||
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
|
||||
outputExports[entryPoint.mount] = entryPoint.path;
|
||||
continue;
|
||||
}
|
||||
const exp = {} as Record<string, string>;
|
||||
|
||||
let exp = {} as Record<string, string>;
|
||||
|
||||
for (const [key, ext] of Object.entries(EXPORT_MAP)) {
|
||||
const name = `${entryPoint.name}${ext}`;
|
||||
if (distFiles.includes(name)) {
|
||||
exp[key] = `./${posixPath.join(`dist`, name)}`;
|
||||
}
|
||||
}
|
||||
|
||||
exp.default = exp.require ?? exp.import;
|
||||
|
||||
// Find the default export type for the entry point, if feature detection is active
|
||||
if (exp.types && featureDetectionProject) {
|
||||
const defaultFeatureType =
|
||||
pkg.backstage?.role &&
|
||||
getEntryPointDefaultFeatureType(
|
||||
pkg.backstage?.role,
|
||||
packageDir,
|
||||
featureDetectionProject,
|
||||
exp.types,
|
||||
);
|
||||
|
||||
if (defaultFeatureType) {
|
||||
// This ensures that the `backstage` field is at the top of the
|
||||
// `exports` field in the package.json because order is important.
|
||||
// https://nodejs.org/docs/latest-v20.x/api/packages.html#conditional-exports
|
||||
exp = { backstage: defaultFeatureType, ...exp };
|
||||
}
|
||||
}
|
||||
|
||||
// This creates a directory with a lone package.json for backwards compatibility
|
||||
if (entryPoint.mount === '.') {
|
||||
if (exp.default) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2024 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 { PackageRole } from '@backstage/cli-node';
|
||||
import createFeatureEnvironment from './__testUtils__/createFeatureEnvironment';
|
||||
import {
|
||||
getEntryPointDefaultFeatureType,
|
||||
BackstagePackageFeatureType,
|
||||
} from './typeDistProject';
|
||||
|
||||
describe('typeDistProject', () => {
|
||||
describe('for package role', () => {
|
||||
// This Record makes sure we're checking all package roles
|
||||
const packageRoles: Record<PackageRole, boolean> = {
|
||||
// Allowed
|
||||
'backend-plugin': true,
|
||||
'backend-plugin-module': true,
|
||||
'frontend-plugin': true,
|
||||
'frontend-plugin-module': true,
|
||||
'web-library': true,
|
||||
'node-library': true,
|
||||
// Disallowed
|
||||
frontend: false,
|
||||
backend: false,
|
||||
cli: false,
|
||||
'common-library': false,
|
||||
};
|
||||
|
||||
const allowedPackageRoles = Object.keys(packageRoles).filter(
|
||||
role => packageRoles[role as PackageRole],
|
||||
);
|
||||
|
||||
const disallowedPackageRoles = Object.keys(packageRoles).filter(
|
||||
role => !packageRoles[role as PackageRole],
|
||||
);
|
||||
|
||||
it.each(allowedPackageRoles)(`returns features for %s`, r => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
role: r as PackageRole,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual('@backstage/BackendFeature');
|
||||
});
|
||||
|
||||
it.each(disallowedPackageRoles)(`does not return features for %s`, r => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
role: r as PackageRole,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for feature $$type', () => {
|
||||
// This Record makes sure we're checking all feature types
|
||||
const featureTypes: Record<BackstagePackageFeatureType | string, boolean> =
|
||||
{
|
||||
// Allowed
|
||||
'@backstage/BackendFeature': true,
|
||||
'@backstage/BackstagePlugin': true,
|
||||
'@backstage/FrontendPlugin': true,
|
||||
'@backstage/FrontendModule': true,
|
||||
// Disallowed
|
||||
'@backstage/Extension': false,
|
||||
'@backstage/RouteRef': false,
|
||||
};
|
||||
|
||||
const allowedFeatureTypes = Object.keys(featureTypes).filter(
|
||||
$$type => featureTypes[$$type as BackstagePackageFeatureType],
|
||||
);
|
||||
|
||||
const disallowedFeatureTypes = Object.keys(featureTypes).filter(
|
||||
$$type => !featureTypes[$$type as BackstagePackageFeatureType],
|
||||
);
|
||||
|
||||
it.each(allowedFeatureTypes)(`returns features for "%s" $$type`, $$type => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
$$type: $$type as BackstagePackageFeatureType,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual($$type);
|
||||
});
|
||||
|
||||
it.each(disallowedFeatureTypes)(
|
||||
`does not return features for "%s" $$type`,
|
||||
$$type => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
$$type: $$type as BackstagePackageFeatureType,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'DefaultExportAssignment',
|
||||
'DefaultExportFromFile',
|
||||
'DefaultExportFromFileAsDefault',
|
||||
'DefaultExportFromFileWithSibling',
|
||||
] as const)('returns features for format "%s"', format => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
format,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual('@backstage/BackendFeature');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2024 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 { PackageRole } from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph';
|
||||
import { paths } from './paths';
|
||||
|
||||
export const createTypeDistProject = async () => {
|
||||
return new Project({
|
||||
tsConfigFilePath: paths.resolveTargetRoot('tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
};
|
||||
|
||||
// A list of the package roles we want to extract features for
|
||||
const targetPackageRoles: PackageRole[] = [
|
||||
'backend-plugin',
|
||||
'backend-plugin-module',
|
||||
'frontend-plugin',
|
||||
'frontend-plugin-module',
|
||||
'web-library',
|
||||
'node-library',
|
||||
];
|
||||
|
||||
// A list of the feature types we want to extract from the project
|
||||
// and include in the metadata
|
||||
const targetFeatureTypes = [
|
||||
'@backstage/BackendFeature',
|
||||
'@backstage/BackstagePlugin',
|
||||
'@backstage/FrontendPlugin',
|
||||
'@backstage/FrontendModule',
|
||||
] as const;
|
||||
|
||||
export type BackstagePackageFeatureType = (typeof targetFeatureTypes)[number];
|
||||
|
||||
export const getEntryPointDefaultFeatureType = (
|
||||
role: PackageRole,
|
||||
packageDir: string,
|
||||
project: Project,
|
||||
entryPoint: string,
|
||||
): BackstagePackageFeatureType | null => {
|
||||
if (isTargetPackageRole(role)) {
|
||||
const distPath = resolvePath(packageDir, entryPoint);
|
||||
|
||||
try {
|
||||
const defaultFeatureType = getSourceFileDefaultFeatureType(
|
||||
project.addSourceFileAtPath(distPath),
|
||||
);
|
||||
|
||||
if (defaultFeatureType) {
|
||||
return defaultFeatureType;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to extract default feature type from ${distPath}, ${error}. ` +
|
||||
'Your package will publish fine but it may be missing metadata about its default feature.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns all exports (default and named) from an entry point
|
||||
// that are valid Backstage package features
|
||||
function getSourceFileDefaultFeatureType(
|
||||
sourceFile: SourceFile,
|
||||
): BackstagePackageFeatureType | null {
|
||||
for (const exportSymbol of sourceFile.getExportSymbols()) {
|
||||
const declaration = exportSymbol.getDeclarations()[0];
|
||||
const exportName = declaration.getSymbol()?.getName();
|
||||
|
||||
if (exportName !== 'default') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let exportType: Type<ts.Type> | undefined;
|
||||
|
||||
if (declaration) {
|
||||
if (declaration.isKind(SyntaxKind.ExportAssignment)) {
|
||||
exportType = declaration.getExpression().getType();
|
||||
} else if (declaration.isKind(SyntaxKind.ExportSpecifier)) {
|
||||
if (!declaration.isTypeOnly()) {
|
||||
exportType = declaration.getType();
|
||||
}
|
||||
} else if (declaration.isKind(SyntaxKind.VariableDeclaration)) {
|
||||
exportType = declaration.getType();
|
||||
}
|
||||
}
|
||||
|
||||
if (exportName && exportType) {
|
||||
const $$type = getBackstagePackageFeature$$TypeFromType(exportType);
|
||||
|
||||
if ($$type) {
|
||||
return $$type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Given a TS type, returns the Backstage package feature $$type value
|
||||
function getBackstagePackageFeature$$TypeFromType(
|
||||
type: Type,
|
||||
): BackstagePackageFeatureType | null {
|
||||
// Returns the concrete type of a generic type
|
||||
const exportType = type.getTargetType() ?? type;
|
||||
|
||||
for (const property of exportType.getProperties()) {
|
||||
if (property.getName() === '$$type') {
|
||||
const $$type = property
|
||||
.getValueDeclaration()
|
||||
?.getText()
|
||||
.match(/(\$\$type: '(?<type>.+)')/)?.groups?.type;
|
||||
|
||||
if ($$type && isTargetFeatureType($$type)) {
|
||||
return $$type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Condition for a package role matches a target package role
|
||||
function isTargetPackageRole(role: PackageRole): boolean {
|
||||
return !!role && targetPackageRoles.includes(role);
|
||||
}
|
||||
|
||||
// Returns whether an export is a valid Backstage package feature type
|
||||
function isTargetFeatureType(
|
||||
type: string | BackstagePackageFeatureType,
|
||||
): type is BackstagePackageFeatureType {
|
||||
return (
|
||||
!!type && targetFeatureTypes.includes(type as BackstagePackageFeatureType)
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ This does not create an actual dependency on these packages and does not bring i
|
||||
Rollup will extract the value of the version field in each package at build time without
|
||||
leaving any imports in place.
|
||||
*/
|
||||
import { version as backendCommon } from '../../../../packages/backend-common/package.json';
|
||||
import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json';
|
||||
import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json';
|
||||
import { version as cli } from '../../../../packages/cli/package.json';
|
||||
@@ -49,7 +48,6 @@ import { version as theme } from '../../../../packages/theme/package.json';
|
||||
import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json';
|
||||
|
||||
export const packageVersions: Record<string, string> = {
|
||||
'@backstage/backend-common': backendCommon,
|
||||
'@backstage/backend-defaults': backendDefaults,
|
||||
'@backstage/backend-plugin-api': backendPluginApi,
|
||||
'@backstage/backend-test-utils': backendTestUtils,
|
||||
|
||||
Vendored
+5
@@ -30,6 +30,11 @@ declare module '@svgr/rollup' {
|
||||
export default function svgr(options?: any): any;
|
||||
}
|
||||
|
||||
declare module 'rollup/parseAst' {
|
||||
export function parseAst(code: string): any;
|
||||
export function parseAstAsync(code: string): any;
|
||||
}
|
||||
|
||||
declare module '@rollup/plugin-yaml';
|
||||
declare module 'react-dev-utils/formatWebpackMessages' {
|
||||
export default function (stats: any): {
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
|
||||
"@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"postpack": "backstage-cli package postpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "{{versionQuery '@backstage/backend-common'}}",
|
||||
"@backstage/backend-defaults": "{{versionQuery '@backstage/backend-defaults'}}",
|
||||
"@backstage/backend-plugin-api": "{{versionQuery '@backstage/backend-plugin-api'}}",
|
||||
"express": "{{versionQuery 'express' '4.17.1'}}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user