diff --git a/.changeset/good-cars-confess.md b/.changeset/good-cars-confess.md new file mode 100644 index 0000000000..12ec838544 --- /dev/null +++ b/.changeset/good-cars-confess.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Configuration schema is now also collected from the root `package.json` if it exists. diff --git a/.changeset/late-kiwis-double.md b/.changeset/late-kiwis-double.md new file mode 100644 index 0000000000..b1ec53546e --- /dev/null +++ b/.changeset/late-kiwis-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Add option to collect configuration schemas from explicit package paths in addition to by package name. diff --git a/docs/conf/defining.md b/docs/conf/defining.md index f13beb9f77..6462f4f60a 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -13,9 +13,10 @@ which during validation is stitched together into a single schema. ## Schema Collection and Definition Schemas are collected from all packages and dependencies in each repo that are a -part of the Backstage ecosystem, including transitive dependencies. The current -definition of "part of the ecosystem" is that a package has at least one -dependency in the `@backstage` namespace, but this is subject to change. +part of the Backstage ecosystem, including the root package and transitive +dependencies. The current definition of "part of the ecosystem" is that a +package has at least one dependency in the `@backstage` namespace or a +`"configSchema"` field in `package.json`, but this is subject to change. Each package is searched for a schema at a single point of entry, a top-level `"configSchema"` field in `package.json`. The field can either contain an diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index a93f040122..3688d60089 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -39,6 +39,8 @@ export async function loadCliConfig(options: Options) { const schema = await loadConfigSchema({ dependencies: localPackageNames, + // Include the package.json in the project root if it exists + packagePaths: [paths.resolveTargetRoot('package.json')], }); const appConfigs = await loadConfig({ diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index bb3ae50d23..355839c5af 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -53,6 +53,7 @@ export function loadConfigSchema( export type LoadConfigSchemaOptions = | { dependencies: string[]; + packagePaths?: string[]; } | { serialized: JsonObject; diff --git a/packages/config-loader/src/lib/schema/collect.test.ts b/packages/config-loader/src/lib/schema/collect.test.ts index ef9d38faa9..3600991717 100644 --- a/packages/config-loader/src/lib/schema/collect.test.ts +++ b/packages/config-loader/src/lib/schema/collect.test.ts @@ -49,7 +49,7 @@ describe('collectConfigSchemas', () => { }), }); - await expect(collectConfigSchemas([])).resolves.toEqual([]); + await expect(collectConfigSchemas([], [])).resolves.toEqual([]); }); it('should find schema in a local package', async () => { @@ -64,7 +64,7 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).resolves.toEqual([ + await expect(collectConfigSchemas(['a'], [])).resolves.toEqual([ { path: path.join('node_modules', 'a', 'package.json'), value: mockSchema, @@ -72,8 +72,34 @@ describe('collectConfigSchemas', () => { ]); }); - it('should find schema in transitive dependencies', async () => { + it('should find schema at explicit package path', async () => { mockFs({ + root: { + 'package.json': JSON.stringify({ + name: 'root', + configSchema: mockSchema, + }), + }, + }); + + await expect( + collectConfigSchemas([], [path.join('root', 'package.json')]), + ).resolves.toEqual([ + { + path: path.join('root', 'package.json'), + value: mockSchema, + }, + ]); + }); + + it('should find schema in transitive dependencies and explicit path', async () => { + mockFs({ + root: { + 'package.json': JSON.stringify({ + name: 'root', + configSchema: { ...mockSchema, title: 'root' }, + }), + }, node_modules: { a: { 'package.json': JSON.stringify({ @@ -124,20 +150,28 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).resolves.toEqual([ - { - path: path.join('node_modules', 'b', 'package.json'), - value: { ...mockSchema, title: 'b' }, - }, - { - path: path.join('node_modules', 'c1', 'package.json'), - value: { ...mockSchema, title: 'c1' }, - }, - { - path: path.join('node_modules', 'd1', 'package.json'), - value: { ...mockSchema, title: 'd1' }, - }, - ]); + await expect( + collectConfigSchemas(['a'], [path.join('root', 'package.json')]), + ).resolves.toEqual( + expect.arrayContaining([ + { + path: path.join('node_modules', 'b', 'package.json'), + value: { ...mockSchema, title: 'b' }, + }, + { + path: path.join('node_modules', 'c1', 'package.json'), + value: { ...mockSchema, title: 'c1' }, + }, + { + path: path.join('node_modules', 'd1', 'package.json'), + value: { ...mockSchema, title: 'd1' }, + }, + { + path: path.join('root', 'package.json'), + value: { ...mockSchema, title: 'root' }, + }, + ]), + ); }); it('should schema of different types', async () => { @@ -171,7 +205,7 @@ describe('collectConfigSchemas', () => { [typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir), }); - await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([ + await expect(collectConfigSchemas(['a', 'b', 'c'], [])).resolves.toEqual([ { path: path.join('node_modules', 'a', 'package.json'), value: { ...mockSchema, title: 'inline' }, @@ -210,7 +244,7 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).rejects.toThrow( + await expect(collectConfigSchemas(['a'], [])).rejects.toThrow( 'Config schema files must be .json or .d.ts, got schema.yaml', ); }); @@ -230,7 +264,7 @@ describe('collectConfigSchemas', () => { [typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir), }); - await expect(collectConfigSchemas(['a'])).rejects.toThrow( + await expect(collectConfigSchemas(['a'], [])).rejects.toThrow( `Invalid schema in ${path.join( 'node_modules', 'a', diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index b363e8e5f3..81a28e9d7b 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -26,8 +26,9 @@ import { getProgramFromFiles, generateSchema } from 'typescript-json-schema'; import { JsonObject } from '@backstage/config'; type Item = { - name: string; + name?: string; parentPath?: string; + packagePath?: string; }; const req = @@ -40,34 +41,47 @@ const req = */ export async function collectConfigSchemas( packageNames: string[], + packagePaths: string[], ): Promise { const visitedPackages = new Set(); const schemas = Array(); const tsSchemaPaths = Array(); const currentDir = await fs.realpath(process.cwd()); - async function processItem({ name, parentPath }: Item) { - // Ensures that we only process each package once. We don't bother with - // loading in schemas from duplicates of different versions, as that's not - // supported by Backstage right now anyway. We may want to change that in - // the future though, if it for example becomes possible to load in two - // different versions of e.g. @backstage/core at once. - if (visitedPackages.has(name)) { - return; - } - visitedPackages.add(name); + async function processItem(item: Item) { + let pkgPath = item.packagePath; - let pkgPath: string; - try { - pkgPath = req.resolve( - `${name}/package.json`, - parentPath && { - paths: [parentPath], - }, - ); - } catch { - // We can somewhat safely ignore packages that don't export package.json, - // as they are likely not part of the Backstage ecosystem anyway. + if (pkgPath) { + const pkgExists = await fs.pathExists(pkgPath); + if (!pkgExists) { + return; + } + } else if (item.name) { + const { name, parentPath } = item; + + // Ensures that we only process each package once. We don't bother with + // loading in schemas from duplicates of different versions, as that's not + // supported by Backstage right now anyway. We may want to change that in + // the future though, if it for example becomes possible to load in two + // different versions of e.g. @backstage/core at once. + if (visitedPackages.has(name)) { + return; + } + visitedPackages.add(name); + + try { + pkgPath = req.resolve( + `${name}/package.json`, + parentPath && { + paths: [parentPath], + }, + ); + } catch { + // We can somewhat safely ignore packages that don't export package.json, + // as they are likely not part of the Backstage ecosystem anyway. + } + } + if (!pkgPath) { return; } @@ -126,9 +140,10 @@ export async function collectConfigSchemas( ); } - await Promise.all( - packageNames.map(name => processItem({ name, parentPath: currentDir })), - ); + await Promise.all([ + ...packageNames.map(name => processItem({ name, parentPath: currentDir })), + ...packagePaths.map(path => processItem({ name: path, packagePath: path })), + ]); const tsSchemas = compileTsSchemas(tsSchemaPaths); diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts index 4620961310..2d4af3454c 100644 --- a/packages/config-loader/src/lib/schema/load.ts +++ b/packages/config-loader/src/lib/schema/load.ts @@ -28,6 +28,7 @@ import { export type LoadConfigSchemaOptions = | { dependencies: string[]; + packagePaths?: string[]; } | { serialized: JsonObject; @@ -44,7 +45,10 @@ export async function loadConfigSchema( let schemas: ConfigSchemaPackageEntry[]; if ('dependencies' in options) { - schemas = await collectConfigSchemas(options.dependencies); + schemas = await collectConfigSchemas( + options.dependencies, + options.packagePaths ?? [], + ); } else { const { serialized } = options; if (serialized?.backstageConfigSchemaVersion !== 1) {