diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index c3fa6e4e23..f154c586fa 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -14,8 +14,6 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 - BACKSTAGE_CACHE_DIR: /.backstage-build-cache - BACKSTAGE_CACHE_MAX_ENTRIES: 2 steps: - uses: actions/checkout@v2 @@ -36,13 +34,6 @@ jobs: with: path: node_modules key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - - name: cache build cache - uses: actions/cache@v2 - with: - path: .backstage-build-cache - key: build-cache-${{ github.sha }} - restore-keys: | - build-cache- - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 52f2760469..3ef0c1fee3 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -15,8 +15,6 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 - BACKSTAGE_CACHE_DIR: /.backstage-build-cache - BACKSTAGE_CACHE_MAX_ENTRIES: 2 steps: - uses: actions/checkout@v2 @@ -35,13 +33,6 @@ jobs: with: path: node_modules key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - - name: cache build cache - uses: actions/cache@v2 - with: - path: .backstage-build-cache - key: build-cache-${{ github.sha }} - restore-keys: | - build-cache- - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: diff --git a/docs/FAQ.md b/docs/FAQ.md index 5fb131528a..5167139d45 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -15,12 +15,12 @@ No, but it can be! Backstage is designed to be a developer portal for all your infrastructure tooling, services, and documentation. So, it's not a monitoring platform — but that doesn't mean you can't integrate a monitoring tool into Backstage by writing -[a plugin](https://github.com/spotify/faq#what-is-a-plugin-in-backstage). +[a plugin](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage). ### How is Backstage licensed? -Backstage was released as free and open software by Spotify and is licensed -under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). +Backstage was released as open sourced software by Spotify and is licensed under +[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). ### Why did we open source Backstage? @@ -99,15 +99,15 @@ plugins. ​ ### What is a "plugin" in Backstage? -​ Plugins are what provide the feature functionality in Backstage. They are used -to integrate different systems into Backstage's frontend, so that the developer -gets a consistent UX, no matter what tool or service is being accessed on the -other side. ​ Each plugin is treated as a self-contained web app and can include -almost any type of content. Plugins all use a common set of platform APIs and -reusable UI components. Plugins can fetch data either from the backend or an API -exposed through the proxy. ​ Learn more about -[the different components](https://github.com/spotify/backstage#overview) that -make up Backstage. ​ +By far, our most-used plugin is our TechDocs plugin, which we use for creating +technical documentation. Our philosophy at Spotify is to treat "docs like code", +where you write documentation using the same workflow as you write your code. +This makes it easier to create, find, and update documentation. We hope to +release +[the open source version](https://github.com/spotify/backstage/issues/687) in +the future. (See also: +"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage)" +above) ### Do I have to write plugins in TypeScript? @@ -156,10 +156,12 @@ Integrators also configure closed source plugins locally from the monorepo. ​ ​ We chose GitHub because it is the tool that we are most familiar with, so that will naturally lead to integrations for GitHub being developed at an early stage. ​ Hosting this project on GitHub does not exclude integrations with -alternatives, such as GitLab or Bitbucket. We believe that in time there will be -plugins that will provide functionality for these tools as well. Hopefully, -contributed by the community! ​ Also note, implementations of Backstage can be -hosted wherever you feel suits your needs best. ​ +alternatives, such as +[GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab) +or Bitbucket. We believe that in time there will be plugins that will provide +functionality for these tools as well. Hopefully, contributed by the community! +​ Also note, implementations of Backstage can be hosted wherever you feel suits +your needs best. ​ ### Who maintains Backstage? diff --git a/docs/service_specification.schema.json b/docs/service_specification.schema.json new file mode 100644 index 0000000000..a17f67d30a --- /dev/null +++ b/docs/service_specification.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "backstage.io/v1alpha1", + "type": "object", + "title": "A JSON Schema for Backstage catalog entities.", + "description": "Each descriptor file has a number of entities. This schema matches each of those.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService", + "description": "Creates Lorems like a pro.", + "labels": { + "product_name": "Random value Generator" + }, + "annnotations": { + "docs": "https://github.com/..../tree/develop/doc" + }, + "teams": [ + { + "name": "Team super great", + "email": "greatTeam@geemel.com" + } + ] + }, + "spec": { + "type": "service", + "lifecycle": "production", + "owner": "tools@example.com" + } + } + ], + "required": ["apiVersion", "kind", "metadata"], + "additionalProperties": false, + "properties": { + "apiVersion": { + "type": "string", + "description": "Version of the specification format for a particular file is written against.", + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "type": "string", + "description": "High level entity type being described, from the Backstage system model.", + "enum": ["Component"] + }, + "metadata": { + "$ref": "#/definitions/metadata" + }, + "spec": { + "$ref": "#/definitions/spec" + } + }, + "definitions": { + "metadata": { + "type": "object", + "description": "Metadata about the entity, i.e. things that aren't directly part of the entity specification itself.", + "required": ["name"], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9A-Z_.-]{1,63}$", + "description": "The name of the entity. This name is both meant for human eyes to recognize the entity, and for machines and other components to reference the entity" + }, + "description": { + "type": "string", + "description": "A human readable description of the entity, to be shown in Backstage. Should be kept short and informative." + }, + "namespace": { + "type": "string", + "description": "The name of a namespace that the entity belongs to." + }, + "labels": { + "type": "object", + "description": "Labels are optional key/value pairs of that are attached to the entity, and their use is identical to kubernetes object labels.", + "additionalProperties": true, + "patternProperties": { + "^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": { + "type": "string", + "pattern": "^[a-z0-9A-Z_.-]{1,63}$" + } + } + }, + "annnotations": { + "type": "object", + "description": "Arbitrary non-identifying metadata attached to the entity, identical in use to kubernetes object annotations.", + "additionalProperties": true, + "patternProperties": { + "^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": { + "type": "string", + "pattern": "^[a-z0-9A-Z_.-]{1,63}$" + } + } + } + } + }, + "spec": { + "type": "object", + "description": "Actual specification data that describes the entity. TODO: shape depend on `kind`", + "required": ["type", "lifecycle", "owner"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "description": "The type of component.", + "examples": ["service"] + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle step that this component is in.", + "examples": ["production"] + }, + "owner": { + "type": "string", + "description": "The owner of the component.", + "examples": ["tools@example.com"] + } + } + } + } +} diff --git a/lerna.json b/lerna.json index aa535f7aeb..5d63f88c11 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.8" + "version": "0.1.1-alpha.9" } diff --git a/packages/app/package.json b/packages/app/package.json index 9a296a913c..6aa82a0b36 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,21 +1,21 @@ { "name": "example-app", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/plugin-catalog": "^0.1.1-alpha.8", - "@backstage/plugin-circleci": "^0.1.1-alpha.8", - "@backstage/plugin-explore": "^0.1.1-alpha.8", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.8", - "@backstage/plugin-register-component": "^0.1.1-alpha.8", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.8", - "@backstage/plugin-sentry": "^0.1.1-alpha.8", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.8", - "@backstage/plugin-welcome": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/plugin-catalog": "^0.1.1-alpha.9", + "@backstage/plugin-circleci": "^0.1.1-alpha.9", + "@backstage/plugin-explore": "^0.1.1-alpha.9", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.9", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.9", + "@backstage/plugin-register-component": "^0.1.1-alpha.9", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.9", + "@backstage/plugin-sentry": "^0.1.1-alpha.9", + "@backstage/plugin-tech-radar": "^0.1.1-alpha.9", + "@backstage/plugin-welcome": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "prop-types": "^15.7.2", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ed0518485a..f4515cbf01 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core'; +import { + createApp, + AlertDisplay, + OAuthRequestDialog, + SignInPage, +} from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; import * as plugins from './plugins'; @@ -24,18 +29,26 @@ import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, plugins: Object.values(plugins), + components: { + SignInPage: props => ( + + ), + }, }); const AppProvider = app.getProvider(); -const AppComponent = app.getRootComponent(); +const AppRouter = app.getRouter(); +const AppRoutes = app.getRoutes(); const App: FC<{}> = () => ( - - - + + + + + ); diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index c265a7f489..1a52e06013 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -40,7 +40,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 7e9dbb71a7..516208bf3b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -17,20 +17,22 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", - "@backstage/catalog-model": "^0.1.1-alpha.8", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.8", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.8", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.8", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.8", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@backstage/catalog-model": "^0.1.1-alpha.9", + "@backstage/config": "^0.1.1-alpha.9", + "@backstage/config-loader": "^0.1.1-alpha.9", + "@backstage/plugin-auth-backend": "^0.1.1-alpha.9", + "@backstage/plugin-catalog-backend": "^0.1.1-alpha.9", + "@backstage/plugin-identity-backend": "^0.1.1-alpha.9", + "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.9", + "@backstage/plugin-sentry-backend": "^0.1.1-alpha.9", "express": "^4.17.1", "knex": "^0.21.1", "sqlite3": "^4.2.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "@types/helmet": "^0.0.47" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0a9d852222..df0d08fa7d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -27,6 +27,8 @@ import { getRootLogger, useHotMemoize, } from '@backstage/backend-common'; +import { ConfigReader, AppConfig } from '@backstage/config'; +import { loadConfig } from '@backstage/config-loader'; import knex from 'knex'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -35,20 +37,26 @@ import scaffolder from './plugins/scaffolder'; import sentry from './plugins/sentry'; import { PluginEnvironment } from './types'; -function createEnv(plugin: string): PluginEnvironment { - const logger = getRootLogger().child({ type: 'plugin', plugin }); - const database = knex({ - client: 'sqlite3', - connection: ':memory:', - useNullAsDefault: true, - }); - database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { - resource.run('PRAGMA foreign_keys = ON', () => {}); - }); - return { logger, database }; +function makeCreateEnv(loadedConfigs: AppConfig[]) { + const config = ConfigReader.fromConfigs(loadedConfigs); + + return (plugin: string): PluginEnvironment => { + const logger = getRootLogger().child({ type: 'plugin', plugin }); + const database = knex({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + return { logger, database, config }; + }; } async function main() { + const createEnv = makeCreateEnv(await loadConfig()); + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 7cf9610dc2..a9c687cbc4 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -17,6 +17,9 @@ import { createRouter } from '@backstage/plugin-auth-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter({ logger }); +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index ce681a6bdd..f7df3d05c6 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -16,8 +16,10 @@ import Knex from 'knex'; import { Logger } from 'winston'; +import { Config } from '@backstage/config'; export type PluginEnvironment = { logger: Logger; database: Knex; + config: Config; }; diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 170fb36281..64f49e34e6 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,12 +1,10 @@ { "name": "@backstage/catalog-model", - "version": "0.1.1-alpha.8", - "main": "dist/index.cjs.js", - "module": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -22,14 +20,15 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@types/yup": "^0.28.2", "lodash": "^4.17.15", "yup": "^0.28.5" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@types/express": "^4.17.6", "@types/jest": "^25.2.2", "@types/lodash": "^4.14.151", - "@types/yup": "^0.28.2", "yaml": "^1.9.2" }, "files": [ diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 4567d8c593..ad24976ad0 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -25,32 +25,13 @@ async function getConfig() { return require(path.resolve('jest.config.ts')); } - const moduleNameMapper = { - '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), - }; - - // Only point to src/ if we're not in CI, there we just build packages first anyway - if (!process.env.CI) { - const LernaProject = require('@lerna/project'); - const project = new LernaProject(path.resolve('.')); - const packages = await project.getPackages(); - - // To avoid having to build all deps inside the monorepo before running tests, - // we point directory to src/ where applicable. - // For example, @backstage/core = /packages/core/src/index.ts is added to moduleNameMapper - for (const pkg of packages) { - const mainSrc = pkg.get('main:src'); - if (mainSrc) { - moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc); - } - } - } - const options = { rootDir: path.resolve('src'), coverageDirectory: path.resolve('coverage'), collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], - moduleNameMapper, + moduleNameMapper: { + '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), + }, // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed // TODO: jest is working on module support, it's possible that we can remove this in the future diff --git a/packages/cli/package.json b/packages/cli/package.json index a4c862351e..96ca8e4a4f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public" @@ -29,8 +29,8 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.7", - "@backstage/config-loader": "^0.1.1-alpha.7", + "@backstage/config": "0.1.1-alpha.9", + "@backstage/config-loader": "^0.1.1-alpha.9", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -40,8 +40,8 @@ "@spotify/eslint-config": "^7.0.1", "@sucrase/webpack-loader": "^2.0.0", "@types/start-server-webpack-plugin": "^2.2.0", - "@types/webpack-node-externals": "^1.7.1", "@types/webpack-env": "^1.15.2", + "@types/webpack-node-externals": "^1.7.1", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index 91531a53ed..2fa3e4a18f 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -15,20 +15,9 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath, relative as relativePath } from 'path'; import { paths } from '../../lib/paths'; -import { getDefaultCacheOptions } from '../../lib/buildCache'; export default async function clean() { - const cacheOptions = getDefaultCacheOptions(); - const packagePath = getPackagePath(cacheOptions.cacheDir); - await fs.remove(cacheOptions.output); - await fs.remove(packagePath); + await fs.remove(paths.resolveTarget('dist')); await fs.remove(paths.resolveTarget('coverage')); } - -function getPackagePath(cacheDir: string) { - const relativePackagePath = relativePath(paths.targetRoot, paths.targetDir); - const packagePath = resolvePath(cacheDir, relativePackagePath); - return packagePath; -} diff --git a/packages/cli/src/commands/watch-deps/index.ts b/packages/cli/src/commands/watch-deps/index.ts deleted file mode 100644 index 61f403883c..0000000000 --- a/packages/cli/src/commands/watch-deps/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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'; -import { run } from '../../lib/run'; -import { createLogPipe } from '../../lib/logging'; -import { watchDeps, Options } from '../../lib/watchDeps'; - -/* - * The watch-deps command is meant to improve iteration speed while working in a large monorepo - * with packages that are built independently, meaning packages depends on each other's build output. - * - * The command traverses all dependencies of the current package within the monorepo, and starts - * watching for updates in all those packages. If a change is detected, we stop listening for changes, - * and instead start up watch mode for that package. Starting watch mode means running the first - * available yarn script out of "build:watch", "watch", or "build" --watch. - */ -export default async (cmd: Command, args: string[]) => { - const options: Options = {}; - - if (cmd.build) { - options.build = true; - } - - await watchDeps(options); - - if (args?.length) { - // Use log pipe to avoid clearing the terminal - const logPipe = createLogPipe(); - - await run(args[0], args.slice(1), { - stdoutLogFunc: logPipe(process.stdout), - stderrLogFunc: logPipe(process.stderr), - }); - } -}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a8b6250a20..e67a52c795 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -126,29 +126,6 @@ const main = (argv: string[]) => { .description('Restores the changes made by the prepack command') .action(lazyAction(() => import('./commands/pack'), 'post')); - program - .command('watch-deps') - .option('--build', 'Build all dependencies on startup') - .description('Watch all dependencies while running another command') - .action(lazyAction(() => import('./commands/watch-deps'), 'default')); - - program - .command('build-cache') - .description('Wrap build command with a cache') - .option( - '--input ', - 'List of input directories that invalidate the cache [.]', - (value, acc) => acc.concat(value), - [], - ) - .option('--output ', 'Output directory to cache', 'dist') - .option( - '--cache-dir ', - 'Cache dir', - '/node_modules/.cache/backstage-builds', - ) - .action(lazyAction(() => import('./commands/build-cache'), 'default')); - program .command('clean') .description('Delete cache directories') diff --git a/packages/cli/src/lib/buildCache/archive.ts b/packages/cli/src/lib/buildCache/archive.ts deleted file mode 100644 index b4a736240d..0000000000 --- a/packages/cli/src/lib/buildCache/archive.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 tar from 'tar'; -import { dirname } from 'path'; - -// packages all files in inputDir into an archive at archivePath, deleting any existing archive -export async function createArchive( - archivePath: string, - inputDir: string, -): Promise { - await fs.remove(archivePath); - await fs.ensureDir(dirname(archivePath)); - await tar.create({ gzip: true, file: archivePath, cwd: inputDir }, ['.']); -} - -// extracts archive at archive path into outputDir, deleting any existing files at outputDir -export async function extractArchive( - archivePath: string, - outputDir: string, -): Promise { - await fs.remove(outputDir); - await fs.ensureDir(outputDir); - await tar.extract({ file: archivePath, cwd: outputDir }); -} diff --git a/packages/cli/src/lib/buildCache/cache.ts b/packages/cli/src/lib/buildCache/cache.ts deleted file mode 100644 index d49eedaba9..0000000000 --- a/packages/cli/src/lib/buildCache/cache.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { resolve as resolvePath, relative as relativePath } from 'path'; -import { runPlain, runCheck } from '../run'; -import { Options } from './options'; -import { extractArchive, createArchive } from './archive'; -import { paths } from '../paths'; -import { version, isDev } from '../version'; - -const INFO_FILE = '.backstage-build-cache'; - -// Result from a cache query -export type CacheQueryResult = { - // True if there was a cache hit - hit: boolean; - // If there is a cache hit and this method is defined, it needs to be called to restore the - // output contents before continuing. - copy?: (outputDir: string) => Promise; - // Call after a successful build to archive the output content. - // The content will be archived using the same key as was used to query the cache. - archive: (outputDir: string, maxEntries: number) => Promise; -}; - -// Key that determines whether cached output can be reused -export type CacheKey = string[]; - -type CacheEntry = { - // Key for the input of this cache entry - key: CacheKey; - // Path to the archive of this cache entry - path: string; -}; - -// Struct containing information about cache entries on the filesystem. -// Stored inside the INFO_FILE as JSON. -type CacheInfo = { - // Optional key entry for the contents of the current directory. Used to key the - // output present in the outputs folder, where the info file resides inside the output folder. - key?: CacheKey; - // Optional list of cache archives present in the same directory. Resides in the external - // cache location inside one info file for each package. - entries?: CacheEntry[]; -}; - -export class Cache { - // Read the current cache state form the filesystem. - static async read(options: Options) { - const relativePackagePath = relativePath(paths.targetRoot, paths.targetDir); - const location = resolvePath(options.cacheDir, relativePackagePath); - - const outputInfo = await readCacheInfo(options.output); - const localKey = outputInfo?.key; - - const { entries = [] } = (await readCacheInfo(location)) ?? {}; - return new Cache(location, entries, localKey); - } - - // Generates a key based on the contents of the input paths. - // Returns undefined if it's not possible to generate a stable key. - static async readInputKey( - inputPaths: string[], - ): Promise { - const allInputPaths = inputPaths.slice(); - - // If we're executing the cli inside the backstage repo, we add the cli src as cache key as well - if (isDev) { - allInputPaths.unshift(paths.ownDir); - } - - // Make sure we don't have any uncommitted changes to the input, in that case we skip caching. - const noChanges = await runCheck( - 'git', - 'diff', - '--quiet', - 'HEAD', - '--', - ...allInputPaths, - ); - if (!noChanges) { - return undefined; - } - - const trees = []; - for (const inputPath of allInputPaths) { - const output = await runPlain('git', 'ls-tree', 'HEAD', inputPath); - const [, , sha] = output.split(/\s+/, 3); - // If we can't get a tree sha it means we're outside of tracked files, so treat as dirty - if (!sha) { - return undefined; - } - trees.push(sha); - } - - if (isDev) { - return trees; - } - // If we're executing as a dependency, use the version as a key - return [version, ...trees]; - } - - constructor( - private readonly location: string, - private readonly entries: CacheEntry[] = [], - private readonly localKey?: CacheKey, - ) {} - - // Query for the presense of cached output for a given key - query(key: CacheKey): CacheQueryResult { - const { location } = this; - - const archive = async (outputDir: string, maxEntries: number) => { - await writeCacheInfo(outputDir, { key }); - - const timestamp = new Date().toISOString().replace(/-|:|\..*/g, ''); - const rand = Math.random().toString(36).slice(2, 6); - const archiveName = `cache-${timestamp}-${rand}.tgz`; - const archivePath = resolvePath(location, archiveName); - - // Read existing entries and prepend the new one - const { entries = [] } = (await readCacheInfo(location)) ?? {}; - - // Check if there's already aan entry for this key, in that case we just wanna bump it - const entryIndex = entries.findIndex((e) => compareKeys(e.key, key)); - if (entryIndex !== -1) { - const [existingEntry] = entries.splice(entryIndex, 1); - entries.unshift(existingEntry); - - await writeCacheInfo(location, { entries }); - return; - } - - // Create and add new archive to entries - await createArchive(archivePath, outputDir); - entries.unshift({ key, path: archiveName }); - - // Remove old cache entries - const removedEntries = entries.splice(maxEntries); - for (const entry of removedEntries) { - try { - await fs.remove(resolvePath(location, entry.path)); - } catch (error) { - process.stderr.write(`failed to remove old cache entry, ${error}\n`); - } - } - - await writeCacheInfo(location, { entries }); - }; - - if (compareKeys(this.localKey, key)) { - return { hit: true, archive }; - } - - const matchingEntry = this.entries.find((e) => compareKeys(e.key, key)); - if (!matchingEntry) { - return { hit: false, archive }; - } - - return { - hit: true, - archive, - copy: async (outputDir: string) => { - const archivePath = resolvePath(location, matchingEntry.path); - await extractArchive(archivePath, outputDir); - }, - }; - } -} - -// Compares to cache keys, returning true if they are both defined and equal -function compareKeys(a?: CacheKey, b?: CacheKey): boolean { - if (!a || !b) { - return false; - } - return a.join(',') === b.join(','); -} - -// Read and parse a cache info file in the given directory -async function readCacheInfo( - parentDir: string, -): Promise { - const infoFile = resolvePath(parentDir, INFO_FILE); - const exists = await fs.pathExists(infoFile); - if (!exists) { - return undefined; - } - - const infoData = await fs.readFile(infoFile); - const cacheInfo = JSON.parse(infoData.toString('utf8')) as CacheInfo; - return cacheInfo; -} - -// Write a cache info file to the given directory -async function writeCacheInfo( - parentDir: string, - cacheInfo: CacheInfo, -): Promise { - const infoData = Buffer.from(JSON.stringify(cacheInfo, null, 2), 'utf8'); - await fs.writeFile(resolvePath(parentDir, INFO_FILE), infoData); -} diff --git a/packages/cli/src/lib/buildCache/options.ts b/packages/cli/src/lib/buildCache/options.ts deleted file mode 100644 index 9a6a379fce..0000000000 --- a/packages/cli/src/lib/buildCache/options.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { resolve as resolvePath } from 'path'; -import { Command } from 'commander'; -import { paths } from '../paths'; - -const DEFAULT_CACHE_DIR = '/node_modules/.cache/backstage-builds'; -const DEFAULT_MAX_ENTRIES = 10; - -export type Options = { - inputs: string[]; - output: string; - cacheDir: string; - maxCacheEntries: number; -}; - -function transformPath(path: string): string { - return resolvePath(path.replace(//g, paths.targetRoot)); -} - -export async function parseOptions(cmd: Command): Promise { - const inputs = cmd.input.map(transformPath) as string[]; - if (inputs.length === 0) { - inputs.push(transformPath('.')); - } - const output = transformPath(cmd.output); - const cacheDir = transformPath( - process.env.BACKSTAGE_CACHE_DIR || cmd.cacheDir, - ); - const maxCacheEntries = - Number(process.env.BACKSTAGE_CACHE_MAX_ENTRIES) || DEFAULT_MAX_ENTRIES; - return { inputs, output, cacheDir, maxCacheEntries }; -} - -export function getDefaultCacheOptions(): Options { - return { - inputs: [transformPath('.')], - output: transformPath('dist'), - cacheDir: transformPath( - process.env.BACKSTAGE_CACHE_DIR || DEFAULT_CACHE_DIR, - ), - maxCacheEntries: - Number(process.env.BACKSTAGE_CACHE_MAX_ENTRIES) || DEFAULT_MAX_ENTRIES, - }; -} diff --git a/packages/cli/src/lib/buildCache/withCache.ts b/packages/cli/src/lib/buildCache/withCache.ts deleted file mode 100644 index d21de94865..0000000000 --- a/packages/cli/src/lib/buildCache/withCache.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { Cache } from './cache'; -import { Options } from './options'; - -function print(msg: string) { - process.stdout.write(`[build-cache] ${msg}\n`); -} - -// Wrap a build function with a cache, which won't call the build function on cache hit -export async function withCache( - options: Options, - buildFunc: () => Promise, -): Promise { - const key = await Cache.readInputKey(options.inputs); - if (!key) { - print('input directory is dirty, skipping cache'); - await fs.remove(options.output); - await buildFunc(); - return; - } - - const cache = await Cache.read(options); - - const cacheResult = cache.query(key); - if (cacheResult.hit) { - if (cacheResult.copy) { - print('external cache hit, copying archive to output folder'); - await cacheResult.copy(options.output); - } else { - print('cache hit, nothing to be done'); - } - return; - } - - print('cache miss, need to build'); - await fs.remove(options.output); - await buildFunc(); - - await cacheResult.archive(options.output, options.maxCacheEntries); -} diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index e6d47573ef..6fe8092e50 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -33,9 +33,6 @@ import { BundlingOptions, BackendBundlingOptions } from './types'; export function resolveBaseUrl(config: Config): URL { const baseUrl = config.getString('app.baseUrl'); - if (!baseUrl) { - throw new Error('app.baseUrl must be set in config'); - } try { return new URL(baseUrl, 'http://localhost:3000'); } catch (error) { @@ -52,9 +49,6 @@ export function createConfig( const { plugins, loaders } = transforms(options); const baseUrl = options.config.getString('app.baseUrl'); - if (!baseUrl) { - throw new Error('app.baseUrl must be set in config'); - } const validBaseUrl = new URL(baseUrl, 'https://backstage-app.dev'); if (checksEnabled) { @@ -115,7 +109,7 @@ export function createConfig( entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry], resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], - mainFields: ['main:src', 'browser', 'module', 'main'], + mainFields: ['browser', 'module', 'main'], plugins: [ new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], @@ -182,10 +176,14 @@ export function createBackendConfig( }, devtool: isDev ? 'cheap-module-eval-source-map' : 'source-map', context: paths.targetPath, - entry: ['webpack/hot/poll?100', paths.targetEntry], + entry: [ + 'webpack/hot/poll?100', + paths.targetEntry, + ...(paths.targetRunFile ? [paths.targetRunFile] : []), + ], resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], - mainFields: ['main:src', 'browser', 'module', 'main'], + mainFields: ['browser', 'module', 'main'], modules: [paths.targetNodeModules, paths.rootNodeModules], plugins: [ new ModuleScopePlugin( diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 1e1cd1298a..70e82d8d48 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -48,10 +48,15 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) { } } + // Backend plugin dev run file + const targetRunFile = paths.resolveTarget('src/run.ts'); + const runFileExists = fs.pathExistsSync(targetRunFile); + return { targetHtml, targetPublic, targetPath: paths.resolveTarget('.'), + targetRunFile: runFileExists ? targetRunFile : undefined, targetDist: paths.resolveTarget('dist'), targetAssets: paths.resolveTarget('assets'), targetSrc: paths.resolveTarget('src'), diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 481241a869..b6e2bebb41 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -171,9 +171,7 @@ export async function installWithLocalDeps(dir: string) { }); // This takes care of pointing all the installed packages from this repo to - // dist instead of the local src. - // For example node_modules/@backstage/core/packages.json is rewritten to point - // types to dist/index.d.ts and the main:src field is removed. + // dist instead of the local src, using the field overrides in publishConfig. // Without this we get type checking errors in the e2e test if (process.env.BACKSTAGE_E2E_CLI_TEST) { Task.section('Patching local dependencies for e2e tests'); @@ -192,7 +190,6 @@ export async function installWithLocalDeps(dir: string) { const depJson = await fs.readJson(depJsonPath); // We want dist to be used for e2e tests - delete depJson['main:src']; for (const key of Object.keys(depJson.publishConfig)) { if (key !== 'access') { depJson[key] = depJson.publishConfig[key]; diff --git a/packages/cli/src/lib/watchDeps/compiler.ts b/packages/cli/src/lib/watchDeps/compiler.ts deleted file mode 100644 index 5ea8efc3e6..0000000000 --- a/packages/cli/src/lib/watchDeps/compiler.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { spawn } from 'child_process'; -import { LogPipe } from '../logging'; -import chalk from 'chalk'; -import { Package } from './packages'; - -export function startCompiler(pkg: Package, logPipe: LogPipe) { - // First we figure out which yarn script is a available, falling back to "build --watch" - const scriptName = ['build:watch', 'watch'].find( - (script) => script in pkg.scripts, - ); - const args = scriptName ? [scriptName] : ['build', '--watch']; - - // Start the watch script inside the dependency - const watch = spawn('yarn', ['run', ...args], { - cwd: pkg.location, - env: { FORCE_COLOR: 'true', ...process.env }, - stdio: 'pipe', - shell: true, - }); - - watch.stdin.end(); - watch.stdout.on('data', logPipe(process.stdout)); - const logErr = logPipe(process.stderr); - watch.stderr.on('data', logErr); - - const promise = new Promise((resolve, reject) => { - watch.on('error', (error) => { - reject(error); - }); - - watch.on('close', (code: number) => { - if (code !== 0) { - const msg = `Compiler exited with code ${code}`; - logErr(chalk.red(msg)); - reject(new Error(msg)); - } else { - resolve(); - } - }); - }); - - return { - promise, - close() { - watch.kill('SIGINT'); - }, - }; -} diff --git a/packages/cli/src/lib/watchDeps/packages.ts b/packages/cli/src/lib/watchDeps/packages.ts deleted file mode 100644 index 7a45524b45..0000000000 --- a/packages/cli/src/lib/watchDeps/packages.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { paths } from '../paths'; - -const LernaProject = require('@lerna/project'); -const PackageGraph = require('@lerna/package-graph'); - -export type Package = { - name: string; - location: string; - scripts: { [name in string]: string }; -}; - -// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist -export async function findAllDeps( - rootPackageName: string, - blacklist: string[], -): Promise { - const project = new LernaProject(paths.targetDir); - const packages = await project.getPackages(); - const graph = new PackageGraph(packages); - - const deps = new Map(); - const searchNames = [rootPackageName]; - - while (searchNames.length) { - const name = searchNames.pop()!; - - if (deps.has(name)) { - continue; - } - - const node = graph.get(name); - if (!node) { - throw new Error(`Package '${name}' not found`); - } - - searchNames.push(...node.localDependencies.keys()); - deps.set(name, node.pkg); - } - - deps.delete(rootPackageName); - for (const name of blacklist) { - deps.delete(name); - } - - return [...deps.values()]; -} diff --git a/packages/cli/src/lib/watchDeps/watchDeps.ts b/packages/cli/src/lib/watchDeps/watchDeps.ts deleted file mode 100644 index c986749d3b..0000000000 --- a/packages/cli/src/lib/watchDeps/watchDeps.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 fs from 'fs-extra'; -import { createLogPipeFactory } from './logger'; -import { findAllDeps } from './packages'; -import { startWatcher, startPackageWatcher } from './watcher'; -import { startCompiler } from './compiler'; -import { run } from '../run'; -import { paths } from '../paths'; - -const PACKAGE_BLACKLIST = [ - // We never want to watch for changes in the cli, but all packages will depend on it. - '@backstage/cli', -]; - -const WATCH_LOCATIONS = ['package.json', 'src', 'assets']; - -export type Options = { - build?: boolean; -}; - -// Start watching for dependency changes. -// The returned promise resolves when watchers have started for all current dependencies. -export async function watchDeps(options: Options = {}) { - const localPackagePath = paths.resolveTarget('package.json'); - - // Rotate through different prefix colors to make it easier to differenciate between different deps - const createLogPipe = createLogPipeFactory([ - chalk.yellow, - chalk.blue, - chalk.magenta, - chalk.green, - chalk.cyan, - ]); - - // Find all direct and transitive local dependencies of the current package. - const packageData = await fs.readFile(localPackagePath, 'utf8'); - const packageJson = JSON.parse(packageData); - const packageName = packageJson.name; - - const deps = await findAllDeps(packageName, PACKAGE_BLACKLIST); - - if (options.build) { - await run('lerna', [ - 'run', - '--scope', - packageName, - '--include-dependencies', - 'build', - ]); - } - - // We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected - const watcher = await startWatcher(deps, WATCH_LOCATIONS, (pkg) => { - startCompiler(pkg, createLogPipe(pkg.name)).promise.catch((error) => { - process.stderr.write(`${error}\n`); - }); - }); - - await startPackageWatcher(localPackagePath, async () => { - const newDeps = await findAllDeps(packageName, PACKAGE_BLACKLIST); - await watcher.update(newDeps); - }); -} diff --git a/packages/cli/src/lib/watchDeps/watcher.ts b/packages/cli/src/lib/watchDeps/watcher.ts deleted file mode 100644 index bcfe766b63..0000000000 --- a/packages/cli/src/lib/watchDeps/watcher.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * 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 { resolve as resolvePath } from 'path'; -import chalk from 'chalk'; -import chokidar from 'chokidar'; -import { Package } from './packages'; -import { createLogFunc } from '../logging'; - -export type Watcher = { - update(newPackages: Package[]): Promise; -}; - -/* - * Watch for changes inside a collection of packages. When a change is detected, stop - * watching and call the callback with the package the change occured in. - * - * The returned promise is resolved once all watchers are ready. - */ -export async function startWatcher( - packages: Package[], - paths: string[], - callback: (pkg: Package) => void, -): Promise { - const watchedPackageLocations = new Set(); - const log = createLogFunc(process.stdout); - - const watchPackage = async (pkg: Package) => { - let signalled = false; - watchedPackageLocations.add(pkg.location); - - const watchLocations = paths.map((path) => resolvePath(pkg.location, path)); - const watcher = chokidar - .watch(watchLocations, { - cwd: pkg.location, - ignoreInitial: true, - disableGlobbing: true, - }) - .on('all', () => { - if (!signalled) { - signalled = true; - callback(pkg); - } - watcher.close(); - }); - - return new Promise((resolve, reject) => { - watcher.on('ready', resolve); - watcher.on('error', reject); - }); - }; - - const update = async (newPackages: Package[]) => { - const promises = new Array>(); - - for (const pkg of newPackages) { - if (watchedPackageLocations.has(pkg.location)) { - continue; - } - - log(chalk.green(`Starting watch of new dependency ${pkg.name}`)); - promises.push(watchPackage(pkg)); - } - - await Promise.all(promises); - }; - - await Promise.all(packages.map(watchPackage)); - - return { update }; -} - -/** - * Watch a package.json for updates - */ -export function startPackageWatcher(packagePath: string, callback: () => void) { - let changed = false; - let working = false; - - const notifyDeps = async () => { - changed = true; - if (working) { - return; - } - working = true; - changed = false; - - try { - await callback(); - } finally { - working = false; - // Keep going if a change was emitted while working - if (changed) { - notifyDeps(); - } - } - }; - - const watcher = chokidar - .watch(packagePath, { - ignoreInitial: true, - disableGlobbing: true, - }) - .on('all', () => { - notifyDeps(); - }); - - return new Promise((resolve, reject) => { - watcher.on('ready', resolve); - watcher.on('error', reject); - }); -} diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index 140d1c1660..1b58a3e5df 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -26,13 +26,16 @@ const app = createApp({ }); const AppProvider = app.getProvider(); -const AppComponent = app.getRootComponent(); +const AppRouter = app.getRouter(); +const AppRoutes = app.getRoutes(); const App: FC<{}> = () => { useStyles(); return ( - + + + ); }; diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs index c590b68324..d916677d33 100644 --- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs +++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs @@ -1,8 +1,7 @@ { "name": "plugin-welcome", "version": "0.0.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "private": true, "publishConfig": { diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index c92795cc04..b04da6b793 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -1,8 +1,7 @@ { "name": "@backstage/plugin-{{id}}", "version": "{{version}}", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", "private": true, diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index de6b26363a..01aa0ed735 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.7", + "@backstage/config": "0.1.1-alpha.9", "fs-extra": "^9.0.0", "yaml": "^1.9.2", "yup": "^0.28.5" diff --git a/packages/config-loader/src/lib/env.test.ts b/packages/config-loader/src/lib/env.test.ts index 6a0a115b24..9ecc80d29a 100644 --- a/packages/config-loader/src/lib/env.test.ts +++ b/packages/config-loader/src/lib/env.test.ts @@ -45,9 +45,12 @@ describe('readEnv', () => { }), ).toEqual([ { - foo: 'bar', - numbers: { a: 1, b: 2, c: false }, - very: { deep: { nested: { config: { object: {} } } } }, + data: { + foo: 'bar', + numbers: { a: 1, b: 2, c: false }, + very: { deep: { nested: { config: { object: {} } } } }, + }, + context: 'env', }, ]); }); diff --git a/packages/config-loader/src/lib/env.ts b/packages/config-loader/src/lib/env.ts index 83f86d4212..aa986931ff 100644 --- a/packages/config-loader/src/lib/env.ts +++ b/packages/config-loader/src/lib/env.ts @@ -42,7 +42,7 @@ const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; export function readEnv(env: { [name: string]: string | undefined; }): AppConfig[] { - let config: JsonObject | undefined = undefined; + let data: JsonObject | undefined = undefined; for (const [name, value] of Object.entries(env)) { if (!value) { @@ -52,7 +52,7 @@ export function readEnv(env: { const key = name.replace(ENV_PREFIX, ''); const keyParts = key.split('_'); - let obj = (config = config ?? {}); + let obj = (data = data ?? {}); for (const [index, part] of keyParts.entries()) { if (!CONFIG_KEY_PART_PATTERN.test(part)) { throw new TypeError(`Invalid env config key '${key}'`); @@ -87,5 +87,5 @@ export function readEnv(env: { } } - return config ? [config] : []; + return data ? [{ data, context: 'env' }] : []; } diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts index b90c53ee6b..719939ad7f 100644 --- a/packages/config-loader/src/lib/reader.test.ts +++ b/packages/config-loader/src/lib/reader.test.ts @@ -38,11 +38,14 @@ describe('readConfigFile', () => { } as ReaderContext); await expect(config).resolves.toEqual({ - app: { - title: 'Test', - x: 1, - y: [true], + data: { + app: { + title: 'Test', + x: 1, + y: [true], + }, }, + context: 'app-config.yaml', }); }); @@ -83,7 +86,10 @@ describe('readConfigFile', () => { }); await expect(config).resolves.toEqual({ - app: 'secret', + data: { + app: 'secret', + }, + context: 'app-config.yaml', }); expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' }); }); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts index 044bf68288..4efaf1986a 100644 --- a/packages/config-loader/src/lib/reader.ts +++ b/packages/config-loader/src/lib/reader.ts @@ -15,15 +15,19 @@ */ import yaml from 'yaml'; +import { basename } from 'path'; import { isObject } from './utils'; -import { JsonValue, JsonObject } from '@backstage/config'; +import { JsonValue, JsonObject, AppConfig } from '@backstage/config'; import { ReaderContext } from './types'; /** * Reads and parses, and validates, and transforms a single config file. * The transformation rewrites any special values, like the $secret key. */ -export async function readConfigFile(filePath: string, ctx: ReaderContext) { +export async function readConfigFile( + filePath: string, + ctx: ReaderContext, +): Promise { const configYaml = await ctx.readFile(filePath); const config = yaml.parse(configYaml); @@ -76,5 +80,5 @@ export async function readConfigFile(filePath: string, ctx: ReaderContext) { if (!isObject(finalConfig)) { throw new TypeError('Expected object at config root'); } - return finalConfig; + return { data: finalConfig, context: basename(filePath) }; } diff --git a/packages/config/package.json b/packages/config/package.json index c67264d4c9..95a5852ae5 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -29,6 +29,9 @@ "postpack": "backstage-cli postpack", "clean": "backstage-cli clean" }, + "dependencies": { + "lodash": "^4.17.15" + }, "devDependencies": { "@types/jest": "^25.2.2", "@types/node": "^12.0.0" diff --git a/packages/config/src/reader.test.ts b/packages/config/src/reader.test.ts index 3d3287dfe7..9d06e0b025 100644 --- a/packages/config/src/reader.test.ts +++ b/packages/config/src/reader.test.ts @@ -37,72 +37,112 @@ const DATA = { }; function expectValidValues(config: ConfigReader) { + expect(config.keys()).toEqual(Object.keys(DATA)); + expect(config.get('zero')).toBe(0); expect(config.getNumber('zero')).toBe(0); expect(config.getNumber('one')).toBe(1); + expect(config.getOptional('true')).toBe(true); expect(config.getBoolean('true')).toBe(true); expect(config.getBoolean('false')).toBe(false); expect(config.getString('string')).toBe('string'); + expect(config.get('strings')).toEqual(['string1', 'string2']); expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); expect(config.getConfig('nested').getNumber('one')).toBe(1); + expect(config.get('nested')).toEqual({ + one: 1, + string: 'string', + strings: ['string1', 'string2'], + }); expect(config.getConfig('nested').getString('string')).toBe('string'); - expect(config.getConfig('nested').getStringArray('strings')).toEqual([ - 'string1', - 'string2', - ]); + expect( + config.getOptionalConfig('nested')!.getStringArray('strings'), + ).toEqual(['string1', 'string2']); + expect(config.getOptional('missing')).toBe(undefined); + expect(config.getOptionalConfig('missing')).toBe(undefined); + expect(config.getOptionalConfigArray('missing')).toBe(undefined); + expect(config.getNumber('zero')).toBe(0); + expect(config.getBoolean('true')).toBe(true); + expect(config.getString('string')).toBe('string'); + expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); const [config1, config2, config3] = config.getConfigArray('nestlings'); expect(config1.getBoolean('boolean')).toBe(true); expect(config2.getString('string')).toBe('string'); expect(config3.getNumber('number')).toBe(42); + expect( + config.getOptionalConfigArray('nestlings')![0].getBoolean('boolean'), + ).toBe(true); } function expectInvalidValues(config: ConfigReader) { + expect(() => config.getBoolean('string')).toThrow( + "Invalid type in config for key 'string' in 'ctx', got string, wanted boolean", + ); expect(() => config.getNumber('string')).toThrow( - 'Invalid type in config for key string, got string, wanted number', + "Invalid type in config for key 'string' in 'ctx', got string, wanted number", ); expect(() => config.getString('one')).toThrow( - 'Invalid type in config for key one, got number, wanted string', + "Invalid type in config for key 'one' in 'ctx', got number, wanted string", ); expect(() => config.getNumber('true')).toThrow( - 'Invalid type in config for key true, got boolean, wanted number', + "Invalid type in config for key 'true' in 'ctx', got boolean, wanted number", ); expect(() => config.getStringArray('null')).toThrow( - 'Invalid type in config for key null, got null, wanted string-array', + "Invalid type in config for key 'null' in 'ctx', got null, wanted string-array", ); expect(() => config.getString('emptyString')).toThrow( - 'Invalid type in config for key emptyString, got empty-string, wanted string', + "Invalid type in config for key 'emptyString' in 'ctx', got empty-string, wanted string", ); expect(() => config.getStringArray('badStrings')).toThrow( - 'Invalid type in config for key badStrings[1], got empty-string, wanted string', + "Invalid type in config for key 'badStrings[1]' in 'ctx', got empty-string, wanted string", ); expect(() => config.getStringArray('worseStrings')).toThrow( - 'Invalid type in config for key worseStrings[1], got number, wanted string', + "Invalid type in config for key 'worseStrings[1]' in 'ctx', got number, wanted string", ); expect(() => config.getStringArray('worstStrings')).toThrow( - 'Invalid type in config for key worstStrings[2], got object, wanted string', + "Invalid type in config for key 'worstStrings[2]' in 'ctx', got object, wanted string", ); expect(() => config.getConfig('one')).toThrow( - 'Invalid type in config for key one, got number, wanted object', + "Invalid type in config for key 'one' in 'ctx', got number, wanted object", ); expect(() => config.getConfigArray('one')).toThrow( - 'Invalid type in config for key one, got number, wanted object-array', + "Invalid type in config for key 'one' in 'ctx', got number, wanted object-array", + ); + expect(() => config.getBoolean('missing')).toThrow( + "Missing required config value at 'missing'", + ); + expect(() => config.getNumber('missing')).toThrow( + "Missing required config value at 'missing'", + ); + expect(() => config.getString('missing')).toThrow( + "Missing required config value at 'missing'", + ); + expect(() => config.getStringArray('missing')).toThrow( + "Missing required config value at 'missing'", ); } +const CTX = 'ctx'; + describe('ConfigReader', () => { it('should read empty config with valid keys', () => { - const config = new ConfigReader({}); - expect(config.getString('x')).toBeUndefined(); - expect(config.getString('x_x')).toBeUndefined(); - expect(config.getString('x-X')).toBeUndefined(); - expect(config.getString('x0')).toBeUndefined(); - expect(config.getString('X-x2')).toBeUndefined(); - expect(config.getString('x0_x0')).toBeUndefined(); - expect(config.getString('x_x-x_x')).toBeUndefined(); + const config = new ConfigReader({}, CTX); + expect(config.keys()).toEqual([]); + expect(config.getOptionalString('x')).toBeUndefined(); + expect(config.getOptionalString('x_x')).toBeUndefined(); + expect(config.getOptionalString('x-X')).toBeUndefined(); + expect(config.getOptionalString('x0')).toBeUndefined(); + expect(config.getOptionalString('X-x2')).toBeUndefined(); + expect(config.getOptionalString('x0_x0')).toBeUndefined(); + expect(config.getOptionalString('x_x-x_x')).toBeUndefined(); + + expect( + new ConfigReader(undefined, CTX).getOptionalString('x'), + ).toBeUndefined(); }); it('should throw on invalid keys', () => { - const config = new ConfigReader({}); + const config = new ConfigReader({}, CTX); expect(() => config.getString('.')).toThrow(/^Invalid config key/); expect(() => config.getString('0')).toThrow(/^Invalid config key/); @@ -119,35 +159,39 @@ describe('ConfigReader', () => { expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/); expect(() => config.getString('a._')).toThrow(/^Invalid config key/); expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/); + + expect(() => new ConfigReader(undefined, CTX).getString('.')).toThrow( + /^Invalid config key/, + ); }); it('should read valid values', () => { - const config = new ConfigReader(DATA); + const config = new ConfigReader(DATA, CTX); expectValidValues(config); }); it('should fail to read invalid values', () => { - const config = new ConfigReader(DATA); + const config = new ConfigReader(DATA, CTX); expectInvalidValues(config); }); }); describe('ConfigReader with fallback', () => { it('should behave as if without fallback', () => { - const config = new ConfigReader({}, new ConfigReader(DATA)); - expect(config.getString('x')).toBeUndefined(); + const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX)); + expect(config.getOptionalString('x')).toBeUndefined(); expect(() => config.getString('.')).toThrow(/^Invalid config key/); expect(() => config.getString('a.')).toThrow(/^Invalid config key/); }); it('should read values from itself', () => { - const config = new ConfigReader(DATA, new ConfigReader({})); + const config = new ConfigReader(DATA, CTX, new ConfigReader({}, CTX)); expectValidValues(config); expectInvalidValues(config); }); it('should read values from a fallback', () => { - const config = new ConfigReader({}, new ConfigReader(DATA)); + const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX)); expectValidValues(config); expectInvalidValues(config); }); @@ -155,12 +199,92 @@ describe('ConfigReader with fallback', () => { it('should read values from multiple levels of fallbacks', () => { const config = new ConfigReader( {}, - new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))), + CTX, + new ConfigReader( + {}, + CTX, + new ConfigReader({}, CTX, new ConfigReader(DATA, CTX)), + ), ); expectValidValues(config); expectInvalidValues(config); }); + it('should show error with correct context', () => { + const config = ConfigReader.fromConfigs([ + { + data: { + c: true, + }, + context: 'x', + }, + { + data: { + b: true, + c: true, + nested1: { + a: true, + }, + badBefore: true, + badAfter: { + a: true, + }, + }, + context: 'y', + }, + { + data: { + a: true, + b: true, + c: true, + nested1: { + a: true, + b: true, + }, + badBefore: { + a: true, + }, + badAfter: true, + }, + context: 'z', + }, + ]); + + expect(() => config.getNumber('a')).toThrow( + "Invalid type in config for key 'a' in 'z', got boolean, wanted number", + ); + expect(() => config.getNumber('b')).toThrow( + "Invalid type in config for key 'b' in 'y', got boolean, wanted number", + ); + expect(() => config.getNumber('c')).toThrow( + "Invalid type in config for key 'c' in 'x', got boolean, wanted number", + ); + expect(() => config.getNumber('nested1.a')).toThrow( + "Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number", + ); + expect(() => config.getNumber('nested1.b')).toThrow( + "Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number", + ); + expect(() => config.getConfig('nested1').getNumber('a')).toThrow( + "Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number", + ); + expect(() => config.getConfig('nested1').getNumber('b')).toThrow( + "Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number", + ); + expect(() => config.getNumber('badBefore.a')).toThrow( + "Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object", + ); + expect(() => config.getNumber('badBefore.b')).toThrow( + "Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object", + ); + expect(() => config.getNumber('badAfter.a')).toThrow( + "Invalid type in config for key 'badAfter.a' in 'y', got boolean, wanted number", + ); + expect(() => config.getNumber('badAfter.b')).toThrow( + "Invalid type in config for key 'badAfter' in 'z', got boolean, wanted object", + ); + }); + it('should read merged objects', () => { const a = { merged: { @@ -181,7 +305,18 @@ describe('ConfigReader with fallback', () => { }, }; - const config = new ConfigReader(a, new ConfigReader(b)); + const config = new ConfigReader(a, CTX, new ConfigReader(b, CTX)); + + expect(config.keys()).toEqual(['merged']); + expect(config.getConfig('merged').keys()).toEqual([ + 'x', + 'z', + 'arr', + 'config', + 'configs', + 'y', + ]); + expect(config.getConfig('merged.config').keys()).toEqual(['d', 'e']); expect(config.getString('merged.x')).toBe('x'); expect(config.getString('merged.y')).toBe('y'); @@ -206,12 +341,19 @@ describe('ConfigReader with fallback', () => { 'a', 'b', ]); + expect(() => config.getConfig('merged').getStringArray('x')).toThrow( + "Invalid type in config for key 'merged.x' in 'ctx', got string, wanted string-array", + ); // Config arrays aren't merged either expect(config.getConfigArray('merged.configs').length).toBe(1); expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a'); + expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a'); + expect(() => + config.getConfigArray('merged.configs')[0].getString('missing'), + ).toThrow("Missing required config value at 'merged.configs[0].missing'"); expect( - config.getConfigArray('merged.configs')[0].getString('b'), + config.getConfigArray('merged.configs')[0].getOptionalString('b'), ).toBeUndefined(); // Config arrays aren't merged either @@ -220,7 +362,173 @@ describe('ConfigReader with fallback', () => { config.getConfig('merged').getConfigArray('configs')[0].getString('a'), ).toBe('a'); expect( - config.getConfig('merged').getConfigArray('configs')[0].getString('b'), + config + .getConfig('merged') + .getConfigArray('configs')[0] + .getOptionalString('b'), ).toBeUndefined(); }); }); + +describe('ConfigReader.get()', () => { + const config1 = { + a: { + x: 'x1', + y: ['y11', 'y12', 'y13'], + z: false, + }, + b: { + x: 'x1', + y: ['y11'], + }, + }; + const config2 = { + b: { + x: 'x2', + y: ['y21', 'y22'], + z: 'z2', + }, + c: { + c1: { + c2: 'c2', + }, + }, + }; + const config3 = { + c: { + c1: 'c1', + }, + }; + const configs = [ + { + data: config1, + context: '1', + }, + { + data: config2, + context: '2', + }, + { + data: config3, + context: '3', + }, + ]; + + it('should be able to select sub-configs', () => { + expect(new ConfigReader(config1).get('a')).toEqual(config1.a); + expect(new ConfigReader(config1).get('b')).toEqual(config1.b); + expect(new ConfigReader(config2).get('b')).toEqual(config2.b); + expect(new ConfigReader(config2).get('c')).toEqual(config2.c); + expect(new ConfigReader(config3).get('c')).toEqual(config3.c); + expect(new ConfigReader(config2).get('c.c1')).toEqual(config2.c.c1); + expect(new ConfigReader(config2).getConfig('c').get('c1')).toEqual( + config2.c.c1, + ); + }); + + it('should merge in fallback configs', () => { + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual( + { + x: 'x1', + y: ['y11', 'y12', 'y13'], + z: false, + }, + ); + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual( + { + x: 'x1', + y: ['y11'], + z: 'z2', + }, + ); + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual( + { + c1: { + c2: 'c2', + }, + }, + ); + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual( + { + x: 'x1', + y: ['y11', 'y12', 'y13'], + z: false, + }, + ); + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual( + { + x: 'x1', + y: ['y11'], + z: 'z2', + }, + ); + expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual( + { + c1: { + c2: 'c2', + }, + }, + ); + + expect( + ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('b'), + ).toEqual({ + x: 'x2', + y: ['y21', 'y22'], + z: 'z2', + }); + expect( + ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('c'), + ).toEqual({ + c1: 'c1', + }); + }); + + it('should not merge non-objects', () => { + const config = ConfigReader.fromConfigs([ + { + data: { + a: ['1', '2'], + c: [], + d: { + x: 'x', + }, + e: ['3'], + f: 'foo', + g: { z: 'z' }, + h: { + a: 'a1', + c: 'c1', + }, + }, + context: '1', + }, + { + data: { + a: ['x', 'y', 'z'], + b: ['1'], + c: ['1'], + d: ['2'], + e: { + y: 'y', + }, + f: { x: 'x' }, + g: 'bar', + h: { + a: 'a2', + b: 'b2', + }, + }, + context: '2', + }, + ]); + expect(config.get('a')).toEqual(['1', '2']); + expect(config.get('b')).toEqual(['1']); + expect(config.get('c')).toEqual([]); + expect(config.get('d')).toEqual({ x: 'x' }); + expect(config.get('e')).toEqual(['3']); + expect(config.get('f')).toEqual('foo'); + expect(config.get('g')).toEqual({ z: 'z' }); + expect(config.get('h')).toEqual({ a: 'a1', b: 'b2', c: 'c1' }); + }); +}); diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 95c4f47383..0669bcb6d8 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -15,6 +15,8 @@ */ import { AppConfig, Config, JsonValue, JsonObject } from './types'; +import cloneDeep from 'lodash/cloneDeep'; +import mergeWith from 'lodash/mergeWith'; // Update the same pattern in config-loader package if this is changed const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; @@ -39,43 +41,106 @@ function typeOf(value: JsonValue | undefined): string { return type; } -export class ConfigReader implements Config { - private static readonly nullReader = new ConfigReader({}); +// Separate out a couple of common error messages to reduce bundle size. +const errors = { + type(key: string, context: string, typeName: string, expected: string) { + return `Invalid type in config for key '${key}' in '${context}', got ${typeName}, wanted ${expected}`; + }, + missing(key: string) { + return `Missing required config value at '${key}'`; + }, +}; +export class ConfigReader implements Config { static fromConfigs(configs: AppConfig[]): ConfigReader { if (configs.length === 0) { - return new ConfigReader({}); + return new ConfigReader(undefined); } // Merge together all configs info a single config with recursive fallback // readers, giving the first config object in the array the highest priority. - return configs.reduceRight((previousReader, nextConfig) => { - return new ConfigReader(nextConfig, previousReader); - }, undefined!); + return configs.reduceRight( + (previousReader, { data, context }) => { + return new ConfigReader(data, context, previousReader); + }, + undefined!, + ); } constructor( - private readonly data: JsonObject, + private readonly data: JsonObject | undefined, + private readonly context: string = 'empty-config', private readonly fallback?: ConfigReader, + private readonly prefix: string = '', ) {} - getConfig(key: string): ConfigReader { + keys(): string[] { + const localKeys = this.data ? Object.keys(this.data) : []; + const fallbackKeys = this.fallback?.keys() ?? []; + return [...new Set([...localKeys, ...fallbackKeys])]; + } + + get(key: string): JsonValue { + const value = this.getOptional(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptional(key: string): JsonValue | undefined { const value = this.readValue(key); - const fallbackConfig = this.fallback?.getConfig(key); + const fallbackValue = this.fallback?.getOptional(key); + + if (value === undefined) { + return fallbackValue; + } else if (fallbackValue === undefined) { + return value; + } + + // Avoid merging arrays and primitive values, since that's how merging works for other + // methods for reading config. + return mergeWith( + {}, + { value: cloneDeep(fallbackValue) }, + { value }, + (into, from) => (!isObject(from) || !isObject(into) ? from : undefined), + ).value; + } + + getConfig(key: string): ConfigReader { + const value = this.getOptionalConfig(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalConfig(key: string): ConfigReader | undefined { + const value = this.readValue(key); + const fallbackConfig = this.fallback?.getOptionalConfig(key); + const prefix = this.fullKey(key); + if (isObject(value)) { - return new ConfigReader(value, fallbackConfig); + return new ConfigReader(value, this.context, fallbackConfig, prefix); } if (value !== undefined) { throw new TypeError( - `Invalid type in config for key ${key}, got ${typeOf( - value, - )}, wanted object`, + errors.type(this.fullKey(key), this.context, typeOf(value), 'object'), ); } - return fallbackConfig ?? ConfigReader.nullReader; + return fallbackConfig; } getConfigArray(key: string): ConfigReader[] { + const value = this.getOptionalConfigArray(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalConfigArray(key: string): ConfigReader[] | undefined { const configs = this.readConfigValue(key, values => { if (!Array.isArray(values)) { return { expected: 'object-array' }; @@ -89,24 +154,60 @@ export class ConfigReader implements Config { return true; }); - return (configs ?? []).map(obj => new ConfigReader(obj)); + if (!configs) { + return undefined; + } + + return configs.map( + (obj, index) => + new ConfigReader( + obj, + this.context, + undefined, + this.fullKey(`${key}[${index}]`), + ), + ); } - getNumber(key: string): number | undefined { + getNumber(key: string): number { + const value = this.getOptionalNumber(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalNumber(key: string): number | undefined { return this.readConfigValue( key, value => typeof value === 'number' || { expected: 'number' }, ); } - getBoolean(key: string): boolean | undefined { + getBoolean(key: string): boolean { + const value = this.getOptionalBoolean(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalBoolean(key: string): boolean | undefined { return this.readConfigValue( key, value => typeof value === 'boolean' || { expected: 'boolean' }, ); } - getString(key: string): string | undefined { + getString(key: string): string { + const value = this.getOptionalString(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalString(key: string): string | undefined { return this.readConfigValue( key, value => @@ -114,7 +215,15 @@ export class ConfigReader implements Config { ); } - getStringArray(key: string): string[] | undefined { + getStringArray(key: string): string[] { + const value = this.getOptionalStringArray(key); + if (value === undefined) { + throw new Error(errors.missing(this.fullKey(key))); + } + return value; + } + + getOptionalStringArray(key: string): string[] | undefined { return this.readConfigValue(key, values => { if (!Array.isArray(values)) { return { expected: 'string-array' }; @@ -128,6 +237,10 @@ export class ConfigReader implements Config { }); } + private fullKey(key: string): string { + return `${this.prefix}${this.prefix ? '.' : ''}${key}`; + } + private readConfigValue( key: string, validate: ( @@ -147,9 +260,13 @@ export class ConfigReader implements Config { value: theValue = value, expected, } = result; - const typeName = typeOf(theValue); throw new TypeError( - `Invalid type in config for key ${keyName}, got ${typeName}, wanted ${expected}`, + errors.type( + this.fullKey(keyName), + this.context, + typeOf(theValue), + expected, + ), ); } } @@ -159,16 +276,25 @@ export class ConfigReader implements Config { private readValue(key: string): JsonValue | undefined { const parts = key.split('.'); - - let value: JsonValue | undefined = this.data; for (const part of parts) { if (!CONFIG_KEY_PART_PATTERN.test(part)) { throw new TypeError(`Invalid config key '${key}'`); } + } + + if (this.data === undefined) { + return undefined; + } + + let value: JsonValue | undefined = this.data; + for (const [index, part] of parts.entries()) { if (isObject(value)) { value = value[part]; - } else { - value = undefined; + } else if (value !== undefined) { + const badKey = this.fullKey(parts.slice(0, index).join('.')); + throw new TypeError( + errors.type(badKey, this.context, typeOf(value), 'object'), + ); } } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 9eda6c8b32..aaadcd70dc 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -24,18 +24,32 @@ export type JsonValue = | boolean | null; -export type AppConfig = JsonObject; +export type AppConfig = { + context: string; + data: JsonObject; +}; export type Config = { + keys(): string[]; + + get(key: string): JsonValue; + getOptional(key: string): JsonValue | undefined; + getConfig(key: string): Config; + getOptionalConfig(key: string): Config | undefined; getConfigArray(key: string): Config[]; + getOptionalConfigArray(key: string): Config[] | undefined; - getNumber(key: string): number | undefined; + getNumber(key: string): number; + getOptionalNumber(key: string): number | undefined; - getBoolean(key: string): boolean | undefined; + getBoolean(key: string): boolean; + getOptionalBoolean(key: string): boolean | undefined; - getString(key: string): string | undefined; + getString(key: string): string; + getOptionalString(key: string): string | undefined; - getStringArray(key: string): string[] | undefined; + getStringArray(key: string): string[]; + getOptionalStringArray(key: string): string[] | undefined; }; diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 80a94b2e34..4e3c8ef139 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", @@ -30,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.7", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/config": "0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -42,8 +41,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/test-utils-core": "^0.1.1-alpha.7", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/test-utils-core": "0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/ApiProvider.tsx index e157782024..24610a1372 100644 --- a/packages/core-api/src/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/ApiProvider.tsx @@ -39,13 +39,19 @@ ApiProvider.propTypes = { children: PropTypes.node, }; -export function useApi(apiRef: ApiRef): T { +export function useApiHolder(): ApiHolder { const apiHolder = useContext(Context); if (!apiHolder) { throw new Error('No ApiProvider available in react context'); } + return apiHolder; +} + +export function useApi(apiRef: ApiRef): T { + const apiHolder = useApiHolder(); + const api = apiHolder.get(apiRef); if (!api) { throw new Error(`No implementation available for ${apiRef}`); diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 20676df899..63a588fc10 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -14,20 +14,7 @@ * limitations under the License. */ import { createApiRef } from '../ApiRef'; - -export type Config = { - getConfig(key: string): Config; - - getConfigArray(key: string): Config[]; - - getNumber(key: string): number | undefined; - - getBoolean(key: string): boolean | undefined; - - getString(key: string): string | undefined; - - getStringArray(key: string): string[] | undefined; -}; +import { Config } from '@backstage/config'; // Using interface to make the ConfigApi name show up in docs export interface ConfigApi extends Config {} diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index bbfab0ef35..5da9ed4814 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -38,9 +38,14 @@ export type IdentityApi = { getIdToken(): string | undefined; // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. + + /** + * Log out the current user + */ + logout(): Promise; }; -export const identifyApiRef = createApiRef({ +export const identityApiRef = createApiRef({ id: 'core.identity', description: 'Provides access to the identity of the signed in user', }); diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index 332636580c..c3689b6703 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { ApiProvider, useApi } from './ApiProvider'; +export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; export { ApiTestRegistry } from './ApiTestRegistry'; export * from './ApiRef'; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 1923adba0d..ec51814f85 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,13 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ComponentType, FC, useMemo } from 'react'; +import React, { + ComponentType, + FC, + useMemo, + useCallback, + useState, + ReactElement, +} from 'react'; import { Route, Routes, Navigate } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; +import { + BackstageApp, + AppComponents, + AppConfigLoader, + Apis, + SignInResult, + SignInPageProps, +} from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; -import { featureFlagsApiRef } from '../apis/definitions'; +import { + featureFlagsApiRef, + AppThemeApi, + ConfigApi, + identityApiRef, +} from '../apis/definitions'; import { AppThemeProvider } from './AppThemeProvider'; import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; @@ -32,9 +51,11 @@ import { appThemeApiRef, configApiRef, ConfigReader, + useApi, } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; +import { AppIdentity } from './AppIdentity'; type FullAppOptions = { apis: Apis; @@ -45,6 +66,41 @@ type FullAppOptions = { configLoader?: AppConfigLoader; }; +function useConfigLoader( + configLoader: AppConfigLoader | undefined, + components: AppComponents, + appThemeApi: AppThemeApi, +): { api: ConfigApi } | { node: JSX.Element } { + // Keeping this synchronous when a config loader isn't set simplifies tests a lot + const hasConfig = Boolean(configLoader); + const config = useAsync(configLoader || (() => Promise.resolve([]))); + + let noConfigNode = undefined; + + if (hasConfig && config.loading) { + const { Progress } = components; + noConfigNode = ; + } else if (config.error) { + const { BootErrorPage } = components; + noConfigNode = ; + } + + // Before the config is loaded we can't use a router, so exit early + if (noConfigNode) { + return { + node: ( + + {noConfigNode} + + ), + }; + } + + const configReader = ConfigReader.fromConfigs(config.value ?? []); + + return { api: configReader }; +} + export class PrivateAppImpl implements BackstageApp { private apis?: ApiHolder = undefined; private readonly icons: SystemIcons; @@ -53,6 +109,8 @@ export class PrivateAppImpl implements BackstageApp { private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private readonly identityApi = new AppIdentity(); + private apisOrFactory: Apis; constructor(options: FullAppOptions) { @@ -79,7 +137,7 @@ export class PrivateAppImpl implements BackstageApp { return this.icons[key]; } - getRootComponent(): ComponentType<{}> { + getRoutes(): ComponentType<{}> { const routes = new Array(); const registeredFeatureFlags = new Array(); @@ -151,71 +209,115 @@ export class PrivateAppImpl implements BackstageApp { [], ); - // Keeping this synchronous when a config loader isn't set simplifies tests a lot - const hasConfig = Boolean(this.configLoader); - const config = useAsync(this.configLoader || (() => Promise.resolve([]))); + const loadedConfig = useConfigLoader( + this.configLoader, + this.components, + appThemeApi, + ); - let noConfigNode = undefined; - - if (hasConfig && config.loading) { - const { Progress } = this.components; - noConfigNode = ; - } else if (config.error) { - const { BootErrorPage } = this.components; - noConfigNode = ( - - ); + if ('node' in loadedConfig) { + return loadedConfig.node; } + const configApi = loadedConfig.api; - // Before the config is loaded we can't use a router, so exit early - if (noConfigNode) { - return ( - - {noConfigNode} - - ); - } - - const configReader = ConfigReader.fromConfigs(config.value ?? []); const appApis = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], - [configApiRef, configReader], + [appThemeApiRef, appThemeApi], + [configApiRef, configApi], + [identityApiRef, this.identityApi], ]); if (!this.apis) { if ('get' in this.apisOrFactory) { this.apis = this.apisOrFactory; } else { - this.apis = this.apisOrFactory(configReader); + this.apis = this.apisOrFactory(configApi); } } const apis = new ApiAggregator(this.apis, appApis); - const { Router } = this.components; + return ( + + + {children} + + + ); + }; + return Provider; + } + + getRouter(): ComponentType<{}> { + const { + Router: RouterComponent, + SignInPage: SignInPageComponent, + } = this.components; + + // This wraps the sign-in page and waits for sign-in to be completed before rendering the app + const SignInPageWrapper: FC<{ + component: ComponentType; + children: ReactElement; + }> = ({ component: Component, children }) => { + const [done, setDone] = useState(false); + + const onResult = useCallback( + (result: SignInResult) => { + if (done) { + throw new Error('Identity result callback was called twice'); + } + this.identityApi.setSignInResult(result); + setDone(true); + }, + [done], + ); + + if (done) { + return children; + } + + return ; + }; + + const AppRouter: FC<{}> = ({ children }) => { + const configApi = useApi(configApiRef); + let { pathname } = new URL( - configReader.getString('app.baseUrl') ?? '/', + configApi.getOptionalString('app.baseUrl') ?? '/', 'http://dummy.dev', // baseUrl can be specified as just a path ); if (pathname.endsWith('/')) { pathname = pathname.replace(/\/$/, ''); } + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + this.identityApi.setSignInResult({ + userId: 'guest', + idToken: undefined, + logout: async () => {}, + }); + + return ( + + + {children}} /> + + + ); + } + return ( - - - - - - {children}} /> - - - - - + + + + {children}} /> + + + ); }; - return Provider; + + return AppRouter; } verify() { diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..77de445a20 --- /dev/null +++ b/packages/core-api/src/app/AppIdentity.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { IdentityApi } from '../apis'; +import { SignInResult } from './types'; + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentity implements IdentityApi { + private hasIdentity = false; + private userId?: string; + private idToken?: string; + private logoutFunc?: () => Promise; + + getUserId(): string { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId!; + } + + getIdToken(): string | undefined { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idToken; + } + + async logout(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi logoutFunc before app was loaded', + ); + } + await this.logoutFunc?.(); + location.reload(); + } + + setSignInResult(result: SignInResult) { + if (this.hasIdentity) { + return; + } + if (!result.userId) { + throw new Error('Invalid sign-in result, userId not set'); + } + this.hasIdentity = true; + this.userId = result.userId; + this.idToken = result.idToken; + this.logoutFunc = result.logout; + } +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index e30c79dd73..a152ef9dfb 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -25,11 +25,45 @@ export type BootErrorPageProps = { step: 'load-config'; error: Error; }; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + /** + * ID token that will be returned by the IdentityApi + */ + idToken?: string; + /** + * Logout handler that will be called if the user requests a logout. + */ + logout?: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; Router: ComponentType<{}>; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; }; /** @@ -117,14 +151,19 @@ export type BackstageApp = { getSystemIcon(key: SystemIconKey): IconComponent; /** - * Creates a root component for this app, including the App chrome - * and routes to all plugins. - */ - getRootComponent(): ComponentType<{}>; - - /** - * Provider component that should wrap the App's RootComponent and - * any other components that need to be within the app context. + * Provider component that should wrap the Router created with getRouter() + * and any other components that need to be within the app context. */ getProvider(): ComponentType<{}>; + + /** + * Router component that should wrap the App Routes create with getRoutes() + * and any other components that should only be available while signed in. + */ + getRouter(): ComponentType<{}>; + + /** + * Routes component that contains all routes for plugin pages in the app. + */ + getRoutes(): ComponentType<{}>; }; diff --git a/packages/core/package.json b/packages/core/package.json index a412af3fa1..f84181f626 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", @@ -30,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.7", - "@backstage/core-api": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/config": "0.1.1-alpha.9", + "@backstage/core-api": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,7 +45,8 @@ "rc-progress": "^3.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-helmet": "6.0.0", + "react-helmet": "6.1.0", + "react-hook-form": "^5.7.2", "react-router": "6.0.0-alpha.5", "react-router-dom": "6.0.0-alpha.5", "react-sparklines": "^1.7.0", @@ -54,8 +54,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx index 30553d84a7..0a3756ad92 100644 --- a/packages/core/src/api-wrappers/createApp.test.tsx +++ b/packages/core/src/api-wrappers/createApp.test.tsx @@ -15,6 +15,7 @@ */ import { defaultConfigLoader } from './createApp'; +import { AppConfig } from '@backstage/config'; describe('defaultConfigLoader', () => { afterEach(() => { @@ -24,24 +25,33 @@ describe('defaultConfigLoader', () => { it('loads static config', async () => { Object.defineProperty(process.env, 'APP_CONFIG', { configurable: true, - value: [{ my: 'config' }, { my: 'override-config' }] as any, + value: [ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ] as AppConfig[], }); const configs = await defaultConfigLoader(); - expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]); + expect(configs).toEqual([ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]); }); it('loads runtime config', async () => { Object.defineProperty(process.env, 'APP_CONFIG', { configurable: true, - value: [{ my: 'override-config' }, { my: 'config' }] as any, + value: [ + { data: { my: 'override-config' }, context: 'a' }, + { data: { my: 'config' }, context: 'b' }, + ] as AppConfig[], }); const configs = await (defaultConfigLoader as any)( '{"my":"runtime-config"}', ); expect(configs).toEqual([ - { my: 'runtime-config' }, - { my: 'override-config' }, - { my: 'config' }, + { data: { my: 'runtime-config' }, context: 'env' }, + { data: { my: 'override-config' }, context: 'a' }, + { data: { my: 'config' }, context: 'b' }, ]); }); @@ -64,7 +74,7 @@ describe('defaultConfigLoader', () => { it('fails to load bad runtime config', async () => { Object.defineProperty(process.env, 'APP_CONFIG', { configurable: true, - value: [{ my: 'config' }] as any, + value: [{ data: { my: 'config' }, context: 'a' }] as AppConfig[], }); await expect((defaultConfigLoader as any)('}')).rejects.toThrow( diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index f69451249b..c13758a0b9 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -27,7 +27,7 @@ import { BrowserRouter, MemoryRouter } from 'react-router-dom'; import { ErrorPage } from '../layout/ErrorPage'; import Progress from '../components/Progress'; import { lightTheme, darkTheme } from '@backstage/theme'; -import { AppConfig } from '@backstage/config'; +import { AppConfig, JsonObject } from '@backstage/config'; const { PrivateAppImpl } = privateExports; @@ -59,7 +59,8 @@ export const defaultConfigLoader: AppConfigLoader = async ( // Avoiding this string also being replaced at runtime if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) { try { - configs.unshift(JSON.parse(runtimeConfigJson)); + const data = JSON.parse(runtimeConfigJson) as JsonObject; + configs.unshift({ data, context: 'env' }); } catch (error) { throw new Error(`Failed to load runtime configuration, ${error}`); } diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 340b39f862..2617a27aa9 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -17,7 +17,7 @@ // TODO(blam): Remove this implementation when the Tabs are ready // This is just a temporary solution to implementing tabs for now -import React from 'react'; +import React, { useState } from 'react'; import { makeStyles, Tabs, Tab } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ @@ -42,9 +42,18 @@ export type Tab = { id: string; label: string; }; -export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => { +export const HeaderTabs: React.FC<{ + tabs: Tab[]; + onChange?: (index: Number) => void; +}> = ({ tabs, onChange }) => { + const [selectedTab, setSelectedTab] = useState(0); const styles = useStyles(); + const handleChange = (_: React.ChangeEvent<{}>, index: Number) => { + setSelectedTab(index); + if (onChange) onChange(index); + }; + return (
= ({ tabs }) => { variant="scrollable" scrollButtons="auto" aria-label="scrollable auto tabs example" - value={0} + onChange={handleChange} + value={selectedTab} > {tabs.map((tab, index) => ( = ({ onClick={handleClick} icon={avatar || AccountCircleIcon} > - {open ? : } + {open ? : } ); diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 8ca2580df9..36e8e1088b 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -17,17 +17,25 @@ import React, { useContext, useEffect } from 'react'; import Collapse from '@material-ui/core/Collapse'; import Star from '@material-ui/icons/Star'; +import SignOutIcon from '@material-ui/icons/MeetingRoom'; import { SidebarContext } from './config'; -import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api'; +import { + googleAuthApiRef, + githubAuthApiRef, + identityApiRef, + useApi, +} from '@backstage/core-api'; import { OAuthProviderSettings, OIDCProviderSettings, UserProfile as SidebarUserProfile, } from './Settings'; +import { SidebarItem } from './Items'; export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); const [open, setOpen] = React.useState(false); + const identityApi = useApi(identityApiRef); // Close the provider list when sidebar collapse useEffect(() => { @@ -48,6 +56,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + identityApi.logout()} + /> ); diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx new file mode 100644 index 0000000000..5911fe86b2 --- /dev/null +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React, { FC } from 'react'; +import { Page } from '../Page'; +import { Header } from '../Header'; +import { Content } from '../Content/Content'; +import { ContentHeader } from '../ContentHeader/ContentHeader'; +import { Grid } from '@material-ui/core'; +import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; +import { useSignInProviders, SignInProviderId } from './providers'; +import Progress from '../../components/Progress'; + +export type Props = SignInPageProps & { + providers: SignInProviderId[]; +}; + +export const SignInPage: FC = ({ onResult, providers }) => { + const configApi = useApi(configApiRef); + + const [loading, providerElements] = useSignInProviders(providers, onResult); + + if (loading) { + return ; + } + + return ( + +
+ + + {providerElements} + + + ); +}; diff --git a/packages/core/src/layout/SignInPage/customProvider.tsx b/packages/core/src/layout/SignInPage/customProvider.tsx new file mode 100644 index 0000000000..8141175d8f --- /dev/null +++ b/packages/core/src/layout/SignInPage/customProvider.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { useForm } from 'react-hook-form'; +import { + Grid, + Typography, + Button, + FormControl, + TextField, + FormHelperText, + makeStyles, +} from '@material-ui/core'; +import isEmpty from 'lodash/isEmpty'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { SignInResult } from '@backstage/core-api'; + +const ID_TOKEN_REGEX = /^[a-z0-9+/]+\.[a-z0-9+/]+\.[a-z0-9+/]+$/i; + +const useFormStyles = makeStyles(theme => ({ + form: { + display: 'flex', + flexFlow: 'column nowrap', + }, + button: { + alignSelf: 'center', + marginTop: theme.spacing(2), + }, +})); + +const Component: ProviderComponent = ({ onResult }) => { + const classes = useFormStyles(); + const { register, handleSubmit, errors, formState } = useForm({ + mode: 'onChange', + }); + + return ( + + + + Enter your own User ID and credentials. +
+ This selection will not be stored. +
+ +
+ + + {errors.userId && ( + {errors.userId.message} + )} + + + + !token || + ID_TOKEN_REGEX.test(token) || + 'Token is not a valid OpenID Connect JWT Token', + })} + /> + {errors.idToken && ( + {errors.idToken.message} + )} + + +
+
+
+ ); +}; + +// Custom provider doesn't store credentials +const loader: ProviderLoader = async () => undefined; + +export const customProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/googleProvider.tsx b/packages/core/src/layout/SignInPage/googleProvider.tsx new file mode 100644 index 0000000000..2db7e2fd53 --- /dev/null +++ b/packages/core/src/layout/SignInPage/googleProvider.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { + useApi, + googleAuthApiRef, + errorApiRef, + ProfileInfo, +} from '@backstage/core-api'; + +function parseUserId(profile: ProfileInfo) { + return profile!.email.replace(/@.*/, ''); +} + +const Component: ProviderComponent = ({ onResult }) => { + const googleAuthApi = useApi(googleAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const idToken = await googleAuthApi.getIdToken({ instantPopup: true }); + const profile = await googleAuthApi.getProfile(); + + onResult({ + userId: parseUserId(profile!), + idToken, + logout: async () => { + await googleAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Google + + + ); +}; + +const loader: ProviderLoader = async apis => { + const googleAuthApi = apis.get(googleAuthApiRef)!; + + const [idToken, profile] = await Promise.all([ + googleAuthApi.getIdToken({ optional: true }), + googleAuthApi.getProfile({ optional: true }), + ]); + + return { + userId: parseUserId(profile!), + idToken, + logout: async () => { + await googleAuthApi.logout(); + }, + }; +}; + +export const googleProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/guestProvider.tsx b/packages/core/src/layout/SignInPage/guestProvider.tsx new file mode 100644 index 0000000000..78d0191ba9 --- /dev/null +++ b/packages/core/src/layout/SignInPage/guestProvider.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; + +const Component: ProviderComponent = ({ onResult }) => ( + + onResult({ userId: 'guest' })} + > + Enter + + } + > + + Enter as a Guest User. +
+ You will not have a verified identity, +
+ meaning some features might be unavailable. +
+
+
+); + +const loader: ProviderLoader = async () => { + return { userId: 'guest' }; +}; + +export const guestProvider: SignInProvider = { Component, loader }; diff --git a/packages/cli/src/lib/buildCache/index.ts b/packages/core/src/layout/SignInPage/index.ts similarity index 91% rename from packages/cli/src/lib/buildCache/index.ts rename to packages/core/src/layout/SignInPage/index.ts index 70bcbd36b8..49f55aefc5 100644 --- a/packages/cli/src/lib/buildCache/index.ts +++ b/packages/core/src/layout/SignInPage/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './withCache'; -export * from './options'; +export { SignInPage } from './SignInPage'; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx new file mode 100644 index 0000000000..d60c8ff281 --- /dev/null +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; +import { guestProvider } from './guestProvider'; +import { googleProvider } from './googleProvider'; +import { customProvider } from './customProvider'; +import { + SignInPageProps, + SignInResult, + useApi, + useApiHolder, + errorApiRef, +} from '@backstage/core-api'; +import { SignInProvider } from './types'; + +const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; + +// Separate list here to avoid exporting internal types +export type SignInProviderId = 'guest' | 'google' | 'custom'; + +const signInProviders: { [id in SignInProviderId]: SignInProvider } = { + guest: guestProvider, + google: googleProvider, + custom: customProvider, +}; + +export const useSignInProviders = ( + providers: SignInProviderId[], + onResult: SignInPageProps['onResult'], +) => { + const errorApi = useApi(errorApiRef); + const apiHolder = useApiHolder(); + const [loading, setLoading] = useState(true); + + // This decorates the result with logout logic from this hook + const handleWrappedResult = useCallback( + (result: SignInResult) => { + onResult({ + ...result, + logout: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await result.logout?.(); + }, + }); + }, + [onResult], + ); + + // In this effect we check if the user has already selected an existing login + // provider, and in that case try to load an existing session for the provider. + useLayoutEffect(() => { + if (!loading) { + return undefined; + } + + // We can't use storageApi here, as it might have a dependency on the IdentityApi + const selectedProvider = localStorage.getItem( + PROVIDER_STORAGE_KEY, + ) as SignInProviderId; + + // No provider selected, let the user pick one + if (selectedProvider === null) { + setLoading(false); + return undefined; + } + + const provider = signInProviders[selectedProvider]; + if (!provider) { + setLoading(false); + return undefined; + } + + let didCancel = false; + provider + .loader(apiHolder) + .then(result => { + if (didCancel) { + return; + } + if (result) { + handleWrappedResult(result); + } + setLoading(false); + }) + .catch(error => { + if (didCancel) { + return; + } + errorApi.post(error); + setLoading(false); + }); + + return () => { + didCancel = true; + }; + }, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]); + + // This renders all available sign-in providers + const elements = useMemo( + () => + providers.map(providerId => { + const provider = signInProviders[providerId]; + if (!provider) { + throw new Error(`Unknown sign-in provider: ${providerId}`); + } + const { Component } = provider; + + const handleResult = (result: SignInResult) => { + localStorage.setItem(PROVIDER_STORAGE_KEY, providerId); + + handleWrappedResult(result); + }; + + return ; + }), + [providers, handleWrappedResult], + ); + + return [loading, elements]; +}; diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/core/src/layout/SignInPage/types.ts similarity index 50% rename from packages/cli/src/commands/build-cache/index.ts rename to packages/core/src/layout/SignInPage/types.ts index 72199ec42e..e13cda5ddd 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/core/src/layout/SignInPage/types.ts @@ -14,19 +14,16 @@ * limitations under the License. */ -import { Command } from 'commander'; -import { run } from '../../lib/run'; -import { withCache, parseOptions } from '../../lib/buildCache'; +import { ComponentType } from 'react'; +import { SignInPageProps, SignInResult, ApiHolder } from '@backstage/core-api'; -/* - * The build-cache command is used to make builds a no-op if there are no changes to the package. - * It supports both local development where the output directory remains intact, as well as CI - * where the output directory is stored in a separate cache dir. - */ -export default async (cmd: Command, args: string[]) => { - const options = await parseOptions(cmd); +export type ProviderComponent = ComponentType; - await withCache(options, async () => { - await run(args[0], args.slice(1)); - }); +export type ProviderLoader = ( + apis: ApiHolder, +) => Promise; + +export type SignInProvider = { + Component: ProviderComponent; + loader: ProviderLoader; }; diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index 9e1298f3d6..e2de159ec6 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -23,5 +23,6 @@ export * from './HomepageTimer'; export * from './InfoCard'; export * from './Page'; export * from './Sidebar'; +export * from './SignInPage'; export * from './TabbedCard'; export * from './HeaderTabs'; diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 5e6d26111d..425d507b17 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", @@ -30,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.7.0", diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 8900ecd54b..0e28a5bf8b 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -82,8 +82,10 @@ class DevAppBuilder { apis: this.setupApiRegistry(this.factories), plugins: this.plugins, }); + const AppProvider = app.getProvider(); - const AppComponent = app.getRootComponent(); + const AppRouter = app.getRouter(); + const AppRoutes = app.getRoutes(); const sidebar = this.setupSidebar(this.plugins); @@ -93,10 +95,13 @@ class DevAppBuilder { {this.rootChildren} - - {sidebar} - - + + + + {sidebar} + + + ); }; diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index d3150400cf..0450f3969d 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -2,6 +2,7 @@ import { ApiRegistry, alertApiRef, errorApiRef, + identityApiRef, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, @@ -19,6 +20,12 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); +builder.add(identityApiRef, { + getUserId: () => 'guest', + getIdToken: () => undefined, + logout: async () => {}, +}); + const oauthRequestApi = builder.add( oauthRequestApiRef, new OAuthRequestManager(), diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index b8f78f5d2c..2ee58a8c4f 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -16,7 +16,7 @@ module.exports = { const coreSrc = path.resolve(__dirname, '../../core/src'); // Mirror config in packages/cli/src/lib/bundler - config.resolve.mainFields = ['main:src', 'browser', 'module', 'main']; + config.resolve.mainFields = ['browser', 'module', 'main']; // Remove the default babel-loader for js files, we're using sucrase instead const [jsLoader] = config.module.rules.splice(0, 1); diff --git a/packages/storybook/package.json b/packages/storybook/package.json index c02557910b..21222cf412 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "description": "Storybook build for core package", "private": true, "scripts": { @@ -14,7 +14,7 @@ ] }, "dependencies": { - "@backstage/theme": "^0.1.1-alpha.8" + "@backstage/theme": "^0.1.1-alpha.9" }, "devDependencies": { "@storybook/addon-actions": "^5.3.17", diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index f0b98add94..3b18ff4c1c 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.1-alpha.7", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a82ec779b8..c0752f87ac 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", @@ -30,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/core-api": "^0.1.1-alpha.8", - "@backstage/test-utils-core": "^0.1.1-alpha.7", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/core-api": "^0.1.1-alpha.9", + "@backstage/test-utils-core": "0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index c474678fd1..15814d918d 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -89,12 +89,15 @@ export function wrapInTestApp( } const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); return ( - {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element - * and work with nested routes if they exist too */} - } /> + + {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element + * and work with nested routes if they exist too */} + } /> + ); } diff --git a/packages/theme/package.json b/packages/theme/package.json index 95a7605c05..1c6c096a8f 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli build --outputs types,esm", @@ -32,7 +31,7 @@ "@material-ui/core": "^4.9.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8" + "@backstage/cli": "^0.1.1-alpha.9" }, "files": [ "dist/**/*.{js,d.ts}" diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 69e4a99acc..d22a5ef6d6 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,10 +1,10 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -20,12 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", - "@types/cookie-parser": "^1.4.2", - "@types/jwt-decode": "2.2.1", - "@types/passport": "^1.0.3", - "@types/passport-github2": "^1.2.4", - "@types/passport-google-oauth20": "^2.0.3", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@backstage/config": "^0.1.1-alpha.9", + "@backstage/config-loader": "^0.1.1-alpha.9", + "@types/express": "^4.17.6", "body-parser": "^1.19.0", "compression": "^1.7.4", "cookie-parser": "^1.4.5", @@ -44,12 +42,17 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "@types/body-parser": "^1.19.0", + "@types/cookie-parser": "^1.4.2", + "@types/jwt-decode": "2.2.1", + "@types/passport-github2": "^1.2.4", + "@types/passport-google-oauth20": "^2.0.3", "@types/passport-saml": "^1.1.2", + "@types/passport": "^1.0.3", "jest-fetch-mock": "^3.0.3" }, "files": [ - "dist" + "dist/**/*.{js,d.ts}" ] } diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts index 4fb80ca3e8..c4d8d2b6e7 100644 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts @@ -37,7 +37,7 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers { async start(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); - provider.start(req, res); + await provider.start(req, res); } async frameHandler( @@ -45,18 +45,18 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers { res: express.Response, ): Promise { const provider = this.getProviderForEnv(req); - provider.frameHandler(req, res); + await provider.frameHandler(req, res); } async refresh(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); if (provider.refresh) { - provider.refresh(req, res); + await provider.refresh(req, res); } } async logout(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); - provider.logout(req, res); + await provider.logout(req, res); } } diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 0f8f185015..0bcd80cf2c 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -96,6 +96,7 @@ export function createGithubProvider( } envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { + disableRefresh: true, providerId: 'github', secure, baseUrl, diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 971f588dce..9675d4eef2 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -143,6 +143,7 @@ export function createGoogleProvider( } envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), { + disableRefresh: false, providerId: 'google', secure, baseUrl, diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 20c35a4084..2e7b4623ff 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -20,9 +20,11 @@ import cookieParser from 'cookie-parser'; import bodyParser from 'body-parser'; import { Logger } from 'winston'; import { createAuthProviderRouter } from '../providers'; +import { Config } from '@backstage/config'; export interface RouterOptions { logger: Logger; + config: Config; } export async function createRouter( @@ -38,7 +40,7 @@ export async function createRouter( // TODO: read from app config const config = { backend: { - baseUrl: 'http://localhost:7000', + baseUrl: options.config.getString('backend.baseUrl'), }, auth: { providers: { @@ -77,7 +79,7 @@ export async function createRouter( const providerConfigs = config.auth.providers; for (const [providerId, providerConfig] of Object.entries(providerConfigs)) { - const baseUrl = `${config.backend.baseUrl}/auth`; + const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`; logger.info(`Configuring provider, ${providerId}`); try { const providerRouter = createAuthProviderRouter( diff --git a/plugins/auth-backend/src/service/standaloneApplication.ts b/plugins/auth-backend/src/service/standaloneApplication.ts index 5eea4fb8f5..cd81e32ccb 100644 --- a/plugins/auth-backend/src/service/standaloneApplication.ts +++ b/plugins/auth-backend/src/service/standaloneApplication.ts @@ -19,6 +19,7 @@ import { notFoundHandler, requestLoggingHandler, } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express from 'express'; @@ -29,12 +30,13 @@ import { createRouter } from './router'; export interface ApplicationOptions { enableCors: boolean; logger: Logger; + config: Config; } export async function createStandaloneApplication( options: ApplicationOptions, ): Promise { - const { enableCors, logger } = options; + const { enableCors, logger, config } = options; const app = express(); app.use(helmet()); @@ -44,7 +46,7 @@ export async function createStandaloneApplication( app.use(compression()); app.use(express.json()); app.use(requestLoggingHandler()); - app.use('/', await createRouter({ logger })); + app.use('/', await createRouter({ logger, config })); app.use(notFoundHandler()); app.use(errorHandler()); diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index be1021d604..c03fe44b40 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -17,6 +17,8 @@ import { Server } from 'http'; import { Logger } from 'winston'; import { createStandaloneApplication } from './standaloneApplication'; +import { ConfigReader } from '@backstage/config'; +import { loadConfig } from '@backstage/config-loader'; export interface ServerOptions { port: number; @@ -28,11 +30,13 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'auth-backend' }); + const config = ConfigReader.fromConfigs(await loadConfig()); logger.debug('Creating application...'); const app = await createStandaloneApplication({ enableCors: options.enableCors, logger, + config, }); logger.debug('Starting application server...'); diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index a350ab4454..a12aa4e21d 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,10 +1,10 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -21,8 +21,9 @@ "mock-data": "./scripts/mock-data" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", - "@backstage/catalog-model": "^0.1.1-alpha.8", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@backstage/catalog-model": "^0.1.1-alpha.9", + "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", @@ -38,7 +39,7 @@ "yup": "^0.28.5" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "@types/lodash": "^4.14.151", "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", @@ -48,7 +49,7 @@ "supertest": "^4.0.2" }, "files": [ - "dist", - "migrations" + "dist/**/*.{js,d.ts}", + "migrations/**/*.{js,d.ts}" ] } diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index d7329e4b2f..ddffb1f932 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-catalog", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.8", - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.8", - "@backstage/plugin-sentry": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/catalog-model": "^0.1.1-alpha.9", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.9", + "@backstage/plugin-sentry": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -39,9 +38,9 @@ "swr": "^0.2.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 3ba63188c1..8c9313ca66 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -25,15 +25,26 @@ import { } from '@backstage/core'; import CatalogLayout from './CatalogLayout'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { Button, Link, makeStyles, Typography } from '@material-ui/core'; +import { + Button, + Link, + makeStyles, + Typography, + withStyles, +} from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import GitHub from '@material-ui/icons/GitHub'; import Star from '@material-ui/icons/Star'; import StarOutline from '@material-ui/icons/StarBorder'; -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useCallback, useState, useMemo } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { catalogApiRef } from '../..'; -import { defaultFilter, entityFilters, filterGroups } from '../../data/filters'; +import { + defaultFilter, + entityFilters, + filterGroups, + EntityFilterType, +} from '../../data/filters'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; import { @@ -43,6 +54,30 @@ import { import { CatalogTable } from '../CatalogTable/CatalogTable'; import useStaleWhileRevalidate from 'swr'; +// TODO: replace me with the proper tabs implemntation +const tabs = [ + { + id: 'service', + label: 'Services', + }, + { + id: 'website', + label: 'Websites', + }, + { + id: 'lib', + label: 'Libraries', + }, + { + id: 'documentation', + label: 'Documentation', + }, + { + id: 'other', + label: 'Other', + }, +]; + const useStyles = makeStyles(theme => ({ contentWrapper: { display: 'grid', @@ -58,8 +93,8 @@ const useStyles = makeStyles(theme => ({ export const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); + const [selectedTab, setSelectedTab] = useState(tabs[0].id); const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); @@ -69,18 +104,27 @@ export const CatalogPage: FC<{}> = () => { async () => catalogApi.getEntities(), ); - const data = - entities?.filter(e => - entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }), - ) ?? []; - const onFilterSelected = useCallback( selected => setSelectedFilter(selected), [], ); + const filteredEntities = useMemo(() => { + const typeFilter = entityFilters[EntityFilterType.TYPE]; + const leftMenuFilter = entityFilters[selectedFilter.id]; + return entities + ?.filter(e => leftMenuFilter(e, { isStarred: isStarredEntity(e) })) + .filter(e => typeFilter(e, { type: selectedTab })); + }, [selectedFilter.id, selectedTab, isStarredEntity, entities?.filter]); + const styles = useStyles(); + const YellowStar = withStyles({ + root: { + color: '#f3ba37', + }, + })(Star); + const actions = [ (rowData: Entity) => { const location = findLocationForEntityMeta(rowData.metadata); @@ -120,40 +164,21 @@ export const CatalogPage: FC<{}> = () => { (rowData: Entity) => { const isStarred = isStarredEntity(rowData); return { - icon: isStarred ? Star : StarOutline, + icon: isStarred ? YellowStar : StarOutline, tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', onClick: () => toggleStarredEntity(rowData), }; }, ]; - // TODO: replace me with the proper tabs implemntation - const tabs = [ - { - id: 'services', - label: 'Services', - }, - { - id: 'websites', - label: 'Websites', - }, - { - id: 'libs', - label: 'Libraries', - }, - { - id: 'documentation', - label: 'Documentation', - }, - { - id: 'other', - label: 'Other', - }, - ]; - return ( - + { + setSelectedTab(tabs[index as number].id); + }} + /> = () => {
diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json b/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json index a70aa122b6..a5506f123d 100644 --- a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json +++ b/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json @@ -88,7 +88,7 @@ "Swedish": "God afton", "Tagalog": "Magandang gabi", "Tatar": "Xäyerle kiç", - "Telugu" : "శుభ సాయంత్రం", + "Telugu": "శుభ సాయంత్రం", "Thai": "Sawat-dii torn khum", "Turkish": "İyi akşamlar", "Ukrainian": "Dobry vechir", diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts index 71efa923c6..bbe959caa3 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -28,6 +28,7 @@ export enum EntityFilterType { ALL = 'ALL', STARRED = 'STARRED', OWNED = 'OWNED', + TYPE = 'TYPE', } export const filterGroups: CatalogFilterGroup[] = [ @@ -54,7 +55,7 @@ export const filterGroups: CatalogFilterGroup[] = [ items: [ { id: EntityFilterType.ALL, - label: 'All Services', + label: 'All Entities', count: AllServicesCount, }, ], @@ -64,13 +65,15 @@ export const filterGroups: CatalogFilterGroup[] = [ type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; type EntityFilterOptions = { - isStarred: boolean; + isStarred?: boolean; + type?: string; }; export const entityFilters: Record = { [EntityFilterType.OWNED]: () => false, [EntityFilterType.ALL]: () => true, - [EntityFilterType.STARRED]: (_, { isStarred }) => isStarred, + [EntityFilterType.STARRED]: (_, { isStarred }) => !!isStarred, + [EntityFilterType.TYPE]: (e, { type }) => (e.spec as any)?.type === type, }; export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 60787b146e..f59d93106a 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-circleci", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -31,12 +30,11 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react-lazylog": "^4.5.0", "circleci-api": "^4.0.0", "moment": "^2.25.3", "react": "^16.13.1", @@ -47,13 +45,14 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", + "@types/react-lazylog": "^4.5.0", "@types/testing-library__jest-dom": "^5.0.4", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/explore/package.json b/plugins/explore/package.json index f0f631eb09..74076e9fbb 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-explore", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,9 +32,9 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 441f2dd77c..ee4406be4c 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,8 +32,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index ad95b1cd52..20e4dc4307 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '@backstage/core-api'; +import { createApiRef } from '@backstage/core'; export interface CloneFromTemplateRequest { templateRepository: string; diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index d7e65c2a26..463e493afc 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -20,7 +20,7 @@ import mockFetch from 'jest-fetch-mock'; import ProfileCatalog from './ProfileCatalog'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; -import { ApiProvider, ApiRegistry } from '@backstage/core-api'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; import { gitOpsApiRef, GitOpsRestApi } from '../../api'; describe('ProfileCatalog', () => { diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 0a6f25e616..9d4a797c82 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "private": false, "publishConfig": { "access": "public", @@ -18,8 +18,7 @@ "backstage" ], "license": "Apache-2.0", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "main": "src/index.ts", "types": "src/index.ts", "scripts": { "build": "backstage-cli plugin:build", @@ -32,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -44,9 +43,9 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/identity-backend/package.json b/plugins/identity-backend/package.json index 862fb273d2..9c2ca5341f 100644 --- a/plugins/identity-backend/package.json +++ b/plugins/identity-backend/package.json @@ -1,10 +1,10 @@ { "name": "@backstage/plugin-identity-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -20,7 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", @@ -32,10 +33,10 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "jest-fetch-mock": "^3.0.3" }, "files": [ - "dist" + "dist/**/*.{js,d.ts}" ] } diff --git a/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts b/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts new file mode 100644 index 0000000000..9a58da2e85 --- /dev/null +++ b/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { + Group, + GroupsResponse, + IdentityApi, + GroupsRequest, + GroupsJson, +} from './types'; + +export class StaticJsonAdapter implements IdentityApi { + private readonly groups: Group[]; + + constructor(userGroups: GroupsJson) { + this.groups = userGroups.groups; + } + + getUserGroups(req: GroupsRequest): Promise { + return new Promise(resolve => { + const { user, type } = req; + const userGroups = this._getUserGroups(this.groups, user); + const groups = this.filterGroupsByType(userGroups, type); + resolve({ groups }); + }); + } + + _getUserGroups(groups: Group[], user: string) { + const userGroups: Set = new Set(); + groups.forEach(group => { + if (this.isUserInGroup(group, user)) { + userGroups.add(group); + } + + if (group.children) { + const userSubGroups = this._getUserGroups(group.children, user) ?? []; + const isUserInSubGroup = Boolean(userSubGroups.length); + if (isUserInSubGroup) { + userGroups.add(group); + } + userSubGroups.forEach(subGroup => userGroups.add(subGroup)); + } + }); + return Array.from(userGroups); + } + + private filterGroupsByType = (userGroups: Group[], type: string) => { + const groups = type + ? userGroups + .filter((group: Group) => group.type === type) + .map(group => ({ name: group.name, type: group.type })) + : userGroups.map(group => ({ + name: group.name, + type: group.type, + })); + return groups; + }; + + private isUserInGroup = (group: Group, user: string): boolean => { + if (group.members) { + const groupMembers = group.members; + const groupsWithUser = groupMembers.filter( + member => member.name === user, + ); + return Boolean(groupsWithUser.length); + } + return false; + }; +} diff --git a/packages/cli/src/lib/watchDeps/index.ts b/plugins/identity-backend/src/adapters/index.ts similarity index 94% rename from packages/cli/src/lib/watchDeps/index.ts rename to plugins/identity-backend/src/adapters/index.ts index a6fc92ca91..357391a25b 100644 --- a/packages/cli/src/lib/watchDeps/index.ts +++ b/plugins/identity-backend/src/adapters/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './watchDeps'; +export * from './StaticJsonAdapter'; diff --git a/packages/cli/src/lib/watchDeps/logger.ts b/plugins/identity-backend/src/adapters/types.ts similarity index 55% rename from packages/cli/src/lib/watchDeps/logger.ts rename to plugins/identity-backend/src/adapters/types.ts index 59eb400301..59fd1324d1 100644 --- a/packages/cli/src/lib/watchDeps/logger.ts +++ b/plugins/identity-backend/src/adapters/types.ts @@ -14,20 +14,29 @@ * limitations under the License. */ -import { createLogPipe } from '../logging'; +export type User = { + name: string; +}; -export type ColorFunc = (msg: string) => string; +export type Group = { + name: string; + type: string; + members?: User[]; + children?: Group[]; +}; -// A factory for creating log pipes that rotate between different coloring functions -export function createLogPipeFactory(colorFuncs: ColorFunc[]) { - let colorIndex = 0; +export type GroupsJson = { + groups: Group[]; +}; - return (name: string) => { - const colorFunc = colorFuncs[colorIndex]; +export type GroupsResponse = { + groups: Group[]; +}; - colorIndex = (colorIndex + 1) % colorFuncs.length; - - const prefix = `${colorFunc(name)}: `; - return createLogPipe({ prefix }); - }; +export type GroupsRequest = { + user: string; + type: string; +}; +export interface IdentityApi { + getUserGroups(req: GroupsRequest): Promise; } diff --git a/plugins/identity-backend/src/adapters/userGroups.ts b/plugins/identity-backend/src/adapters/userGroups.ts new file mode 100644 index 0000000000..806ca1f43f --- /dev/null +++ b/plugins/identity-backend/src/adapters/userGroups.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 userGroups = { + groups: [ + { + name: 'engineering', + type: 'org', + children: [ + { + name: 'authentication', + type: 'team', + members: [{ name: 'kent' }, { name: 'dobbs' }], + }, + { + name: 'checkout', + type: 'team', + members: [{ name: 'don' }, { name: 'abramev' }], + }, + ], + }, + ], +}; diff --git a/plugins/identity-backend/src/service/router.ts b/plugins/identity-backend/src/service/router.ts index 52e73db0fb..9c6515ac04 100644 --- a/plugins/identity-backend/src/service/router.ts +++ b/plugins/identity-backend/src/service/router.ts @@ -17,21 +17,32 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { StaticJsonAdapter } from '../adapters'; +import { IdentityApi } from '../adapters/types'; +import { userGroups } from '../adapters/userGroups'; export interface RouterOptions { logger: Logger; } +const makeRouter = (adapter: IdentityApi): express.Router => { + const router = Router(); + router.get('/users/:user/groups', async (req, res) => { + const user = req.params.user; + const type = req.query.type?.toString() ?? ''; + + const response = await adapter.getUserGroups({ user, type }); + res.send(response); + }); + return router; +}; + export async function createRouter( options: RouterOptions, ): Promise { - const router = Router(); const logger = options.logger; - router.use('/ping', (_, res) => { - logger.info('heartbeat for identity service'); - res.send('pong'); - }); - - return router; + logger.info('Initializing identity API backend'); + const adapter = new StaticJsonAdapter(userGroups); + return makeRouter(adapter); } diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index f53ae0930d..c96013a814 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,8 +1,7 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", "private": false, @@ -22,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,9 +33,9 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", - "@backstage/test-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", + "@backstage/test-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index d5fc49405d..e4c84e448d 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-register-component", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.8", - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/plugin-catalog": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/catalog-model": "^0.1.1-alpha.9", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/plugin-catalog": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,8 +36,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 1838738c01..d6ac8d9ec9 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,10 +1,10 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -21,8 +21,8 @@ "mock-data": "./scripts/mock-data" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", - "@backstage/catalog-model": "^0.1.1-alpha.8", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@backstage/catalog-model": "^0.1.1-alpha.9", "compression": "^1.7.4", "cors": "^2.8.5", "dockerode": "^3.2.0", @@ -35,10 +35,14 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "@types/fs-extra": "^9.0.1", + "@types/express": "^4.17.6", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "yaml": "^1.10.0" - } + }, + "files": [ + "dist/**/*.{js,d.ts}" + ] } diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 66738acea6..5c6859cf57 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,8 +32,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index aa1cf36082..d675b59f52 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -20,23 +20,33 @@ import { Content, ContentHeader, Header, + SupportButton, Page, pageTheme, } from '@backstage/core'; -import { Typography, Link, Button } from '@material-ui/core'; +import { Button, Grid, Link, Typography } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import TemplateCard from '../TemplateCard'; // TODO(blam): Connect to backend const STATIC_DATA = [ + { + id: 'springboot-template', + type: 'service', + name: 'Spring Boot Service', + tags: ['Recommended', 'Java'], + description: + 'Standard Spring Boot (Java) microservice with recommended configuration.', + ownerId: 'spotify', + }, { id: 'react-ssr-template', - type: 'web-infra', + type: 'website', name: 'SSR React Website', - tags: ['Experimental'], + tags: ['Recommended', 'React'], description: 'Next.js application skeleton for creating isomorphic web applications.', - ownerId: 'something', + ownerId: 'spotify', }, ]; const ScaffolderPage: React.FC<{}> = () => { @@ -46,7 +56,7 @@ const ScaffolderPage: React.FC<{}> = () => { pageTitleOverride="Create a new component" title={ <> - Create a new component {' '} + Create a new component } subtitle="Create new software components using standard templates" @@ -61,6 +71,11 @@ const ScaffolderPage: React.FC<{}> = () => { > Register existing component + + Create new software components using standard templates. Different + templates create different kinds of components (services, websites, + documentation, ...). + NOTE! This feature is WIP. You can follow progress{' '} @@ -69,7 +84,7 @@ const ScaffolderPage: React.FC<{}> = () => { . -
+ {STATIC_DATA.map(item => { return ( = () => { /> ); })} -
+ ); diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json index 8652ecda7c..a23cc1ab09 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -1,10 +1,10 @@ { "name": "@backstage/plugin-sentry-backend", - "version": "0.1.1-alpha.8", + "version": "0.1.1-alpha.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.cjs.js", @@ -20,7 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.8", + "@backstage/backend-common": "^0.1.1-alpha.9", + "@types/express": "^4.17.6", "axios": "^0.19.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -33,10 +34,10 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", "jest-fetch-mock": "^3.0.3" }, "files": [ - "dist" + "dist/**/*.{js,d.ts}" ] } diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index dcec4eceb0..80435cf38a 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,11 +1,10 @@ { "name": "@backstage/plugin-sentry", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -22,11 +21,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-sparklines": "^1.7.0", @@ -34,8 +34,8 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 00d008bbd1..16a36dba5d 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,8 +1,7 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", "private": false, @@ -22,9 +21,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/test-utils-core": "^0.1.1-alpha.7", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/test-utils-core": "0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,8 +36,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 388f0335a7..deed9012da 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,10 +1,9 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.8", - "main": "dist/index.esm.js", - "main:src": "src/index.ts", + "version": "0.1.1-alpha.9", + "main": "src/index.ts", "types": "src/index.ts", - "private": true, + "private": false, "license": "Apache-2.0", "publishConfig": { "access": "public", @@ -22,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.8", - "@backstage/theme": "^0.1.1-alpha.8", + "@backstage/core": "^0.1.1-alpha.9", + "@backstage/theme": "^0.1.1-alpha.9", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,8 +32,8 @@ "react-use": "^14.2.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.8", - "@backstage/dev-utils": "^0.1.1-alpha.8", + "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/dev-utils": "^0.1.1-alpha.9", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 6a1b0e0008..ce151737b3 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -39,7 +39,8 @@ import { } from '@backstage/core'; const WelcomePage: FC<{}> = () => { - const appTitle = useApi(configApiRef).getString('app.title') ?? 'Backstage'; + const appTitle = + useApi(configApiRef).getOptionalString('app.title') ?? 'Backstage'; const profile = { givenName: '' }; return ( diff --git a/yarn.lock b/yarn.lock index ebcd9a20a2..b1b521b438 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2452,16 +2452,7 @@ is-module "^1.0.0" resolve "^1.14.2" -"@rollup/pluginutils@^3.0.8": - version "3.0.10" - resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.10.tgz#a659b9025920378494cd8f8c59fbf9b3a50d5f12" - integrity sha512-d44M7t+PjmMrASHbhgpSbVgtL6EFyX7J4mYxwQ/c5eoaE6N2VgCgEcWVzNnwycIloti+/MpwFr8qfw+nRw00sw== - dependencies: - "@types/estree" "0.0.39" - estree-walker "^1.0.1" - picomatch "^2.2.2" - -"@rollup/pluginutils@^3.1.0": +"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== @@ -3949,9 +3940,9 @@ "@types/react" "*" "@types/react@*", "@types/react@^16.9": - version "16.9.25" - resolved "https://registry.npmjs.org/@types/react/-/react-16.9.25.tgz#6ae2159b40138c792058a23c3c04fd3db49e929e" - integrity sha512-Dlj2V72cfYLPNscIG3/SMUOzhzj7GK3bpSrfefwt2YT9GLynvLCCZjbhyF6VsT0q0+aRACRX03TDJGb7cA0cqg== + version "16.9.37" + resolved "https://registry.npmjs.org/@types/react/-/react-16.9.37.tgz#8fb93e7dbd5b1d3796f69aa979a7fe0439bc7bea" + integrity sha512-ZqnAXallQiZ08LTSqMfWMNvAfJEzRLOxdlbbbCIJlYGjU98BEU6bE2uBpKPGeWn+v3hIgCraHKtqUcKZBzMP/Q== dependencies: "@types/prop-types" "*" csstype "^2.2.0" @@ -8121,10 +8112,10 @@ es6-shim@^0.35.5: resolved "https://registry.npmjs.org/es6-shim/-/es6-shim-0.35.5.tgz#46f59dc0a84a1c5029e8ff1166ca0a902077a9ab" integrity sha512-E9kK/bjtCQRpN1K28Xh4BlmP8egvZBGJJ+9GtnzOwt7mdqtrjHFuVGr7QJfdjBIKqrlU5duPf3pCBoDrkjVYFg== -esbuild@^0.4.11: - version "0.4.14" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.4.14.tgz#19c3ec44fe3bb6c637dd5287d870a87d36352dcc" - integrity sha512-8lx+KpHMQM6t3JFutzCWLckcaVQyv5qvdCzWHQdXGGh16SXdv5nfo/+izWcF367PlMkCMfV7iWW7J5I8Skx7ZQ== +esbuild@^0.5.3: + version "0.5.3" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.5.3.tgz#18f5bdb618220c6f14bcb1cf5af528d02d4734c9" + integrity sha512-RVzTK62svYjnh+agJRh+NWfZX74iKwFNUX52cF7Mo4QPS6bKxP1o+8GacPUMND2QnodVp2D3nKJs8gLspSfZzA== escape-goat@^2.0.0: version "2.1.1" @@ -12177,9 +12168,9 @@ linkify-it@^2.0.0: uc.micro "^1.0.1" lint-staged@^10.1.0: - version "10.2.9" - resolved "https://registry.npmjs.org/lint-staged/-/lint-staged-10.2.9.tgz#6013ecfa80829cd422446b545fd30a96bca3098c" - integrity sha512-ziRAuXEqvJLSXg43ezBpHxRW8FOJCXISaXU//BWrxRrp5cBdRkIx7g5IsB3OI45xYGE0S6cOacfekSjDyDKF2g== + version "10.2.11" + resolved "https://registry.npmjs.org/lint-staged/-/lint-staged-10.2.11.tgz#713c80877f2dc8b609b05bc59020234e766c9720" + integrity sha512-LRRrSogzbixYaZItE2APaS4l2eJMjjf5MbclRZpLJtcQJShcvUzKXsNeZgsLIZ0H0+fg2tL4B59fU9wHIHtFIA== dependencies: chalk "^4.0.0" cli-truncate "2.1.0" @@ -15485,6 +15476,11 @@ react-fast-compare@^2.0.4: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== +react-fast-compare@^3.1.1: + version "3.2.0" + resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-focus-lock@^2.1.0: version "2.2.1" resolved "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.2.1.tgz#1d12887416925dc53481914b7cedd39494a3b24a" @@ -15508,14 +15504,14 @@ react-helmet-async@^1.0.2: react-fast-compare "^2.0.4" shallowequal "^1.1.0" -react-helmet@6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/react-helmet/-/react-helmet-6.0.0.tgz#fcb93ebaca3ba562a686eb2f1f9d46093d83b5f8" - integrity sha512-My6S4sa0uHN/IuVUn0HFmasW5xj9clTkB9qmMngscVycQ5vVG51Qp44BEvLJ4lixupTwDlU9qX1/sCrMN4AEPg== +react-helmet@6.1.0: + version "6.1.0" + resolved "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" + integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== dependencies: object-assign "^4.1.1" prop-types "^15.7.2" - react-fast-compare "^2.0.4" + react-fast-compare "^3.1.1" react-side-effect "^2.1.0" react-hook-form@^5.7.2: @@ -16408,12 +16404,12 @@ rollup-plugin-dts@^1.4.6: "@babel/code-frame" "^7.8.3" rollup-plugin-esbuild@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.0.0.tgz#ec6a779f5680410801ad47be9fa9447d02cef5f0" - integrity sha512-a5jeHL9Ay1xc8RUULcqkHQ6poMMYCbcTYmfFlYavLg3ALXeqhlmueWAZMIMvr8YFit0Ru75/uKKpBRmN395gEA== + version "2.1.0" + resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.1.0.tgz#8e12337c63a5b1144e0c5e8adf2f1568ad4d7d69" + integrity sha512-XYqmwk4X0SPEExgilARbre/PplhLtE3q6wiZtfgIbwxJOVGXWec1Bkcux7TFTHGX3TQozzqEASTsRJCt7py/5Q== dependencies: "@rollup/pluginutils" "^3.1.0" - esbuild "^0.4.11" + esbuild "^0.5.3" rollup-plugin-image-files@^1.4.2: version "1.4.2" @@ -17767,9 +17763,9 @@ svgo@^1.0.0, svgo@^1.2.2: util.promisify "~1.0.0" swr@^0.2.2: - version "0.2.2" - resolved "https://registry.npmjs.org/swr/-/swr-0.2.2.tgz#6e1b3e5c0e545c4fdb36ae3aa38cd94d0f9a88b7" - integrity sha512-D/z+PTUchZhoUA0tNC8TNJivf7Hc61WPxbUdXPi+VxRloddWYNP1ZicaEgyAph42ZnKl1L7twcZr4q6d0UMXcg== + version "0.2.3" + resolved "https://registry.npmjs.org/swr/-/swr-0.2.3.tgz#e0fb260d27f12fafa2388312083368f45127480d" + integrity sha512-JhuuD5ojqgjAQpZAhoPBd8Di0Mr1+ykByVKuRJdtKaxkUX/y8kMACWKkLgLQc8pcDOKEAnbIreNjU7HfqI9nHQ== dependencies: fast-deep-equal "2.0.1"