From c38c9e816925f793ed1cb8b8c80d6192962182f2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:05:33 +0200 Subject: [PATCH] frontend-app-api: add support for plugin info resolution and overrides Signed-off-by: Patrik Oldsberg --- .changeset/stupid-flies-speak.md | 5 + packages/frontend-app-api/config.d.ts | 66 ++++ packages/frontend-app-api/report.api.md | 19 +- .../wiring/createPluginInfoAttacher.test.ts | 284 ++++++++++++++++++ .../src/wiring/createPluginInfoAttacher.ts | 247 +++++++++++++++ .../src/wiring/createSpecializedApp.test.tsx | 119 ++++++++ .../src/wiring/createSpecializedApp.tsx | 12 +- packages/frontend-app-api/src/wiring/index.ts | 1 + 8 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 .changeset/stupid-flies-speak.md create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts diff --git a/.changeset/stupid-flies-speak.md b/.changeset/stupid-flies-speak.md new file mode 100644 index 0000000000..cad07ae611 --- /dev/null +++ b/.changeset/stupid-flies-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overriden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration. diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index fc265e8833..3b86b5c1d4 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -51,5 +51,71 @@ export interface Config { }; } >; + + /** + * This section enables you to override certain properties of specific or + * groups of plugins. + * + * @remarks + * All matching entries will be applied to each plugin, with the later + * entries taking precedence. + * + * This configuration is intended to be used primarily to apply overrides + * for third-party plugins. + * + * @deepVisibility frontend + */ + pluginOverrides?: Array<{ + /** + * The criteria for matching plugins to override. + * + * @remarks + * If no match criteria are provided, the override will be applied to + * all plugins. + */ + match?: { + /** + * A pattern that is matched against the plugin ID. + * + * @remarks + * By default the string is interpreted as a glob pattern, but if the + * string is surrounded by '/' it is interpreted as a regex. + */ + pluginId?: string; + + /** + * A pattern that is matched against the package name. + * + * @remarks + * By default the string is interpreted as a glob pattern, but if the + * string is surrounded by '/' it is interpreted as a regex. + * + * Note that this will only work for plugins that provide a + * `package.json` info loader. + */ + packageName?: string; + }; + /** + * Overrides individual top-level fields of the plugin info. + */ + info: { + /** + * Override the description of the plugin. + */ + description?: string; + /** + * Override the owner entity references of the plugin. + * + * @remarks + * The provided values are interpreted as entity references defaulting + * to Group entities in the default namespace. + */ + ownerEntityRefs?: string[]; + /** + * Override the links of the plugin. + */ + links?: Array<{ title: string; url: string }>; + }; + }>; }; } diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index d2a3ac4d3b..7228777561 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api'; +import { FrontendPluginInfo } from '@backstage/frontend-plugin-api'; +import { JsonObject } from '@backstage/types'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; @@ -27,7 +29,7 @@ export type CreateAppRouteBinder = < // @public export function createSpecializedApp(options?: { - features?: FrontendFeature[]; + features?: FrontendFeature_2[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; apis?: ApiHolder; @@ -37,6 +39,7 @@ export function createSpecializedApp(options?: { flags?: { allowUnknownExtensionConfig?: boolean; }; + pluginInfoResolver?: FrontendPluginInfoResolver; }): { apis: ApiHolder; tree: AppTree; @@ -44,4 +47,18 @@ export function createSpecializedApp(options?: { // @public @deprecated (undocumented) export type FrontendFeature = FrontendFeature_2; + +// @public +export type FrontendPluginInfoResolver = (ctx: { + packageJson(): Promise; + manifest(): Promise; + defaultResolver(sources: { + packageJson: JsonObject | undefined; + manifest: JsonObject | undefined; + }): Promise<{ + info: FrontendPluginInfo; + }>; +}) => Promise<{ + info: FrontendPluginInfo; +}>; ``` diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts new file mode 100644 index 0000000000..5595c04135 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts @@ -0,0 +1,284 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockApis } from '@backstage/test-utils'; +import { createPluginInfoAttacher } from './createPluginInfoAttacher'; +import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { + createFrontendPlugin, + FrontendFeature, +} from '@backstage/frontend-plugin-api'; + +function getInfo(plugin: FrontendFeature) { + return OpaqueFrontendPlugin.toInternal(plugin).info(); +} + +describe('createPluginInfoAttacher', () => { + const mockConfig = mockApis.config({ + data: { + app: { + pluginOverrides: [ + { + match: { + pluginId: '/^.*-tester$/', + }, + info: { + description: 'Overridden description', + }, + }, + { + match: { + pluginId: '/^not-.*-tester$/', + }, + info: { + ownerEntityRefs: ['test-group'], + }, + }, + { + match: { + packageName: '@test/package', + }, + info: { + description: 'Package name matched', + }, + }, + { + match: { + pluginId: 'info-tester', + }, + info: { + links: [{ title: 'Custom Link', url: 'https://example.com' }], + }, + }, + ], + }, + }, + }); + + describe('with default resolver', () => { + const attacher = createPluginInfoAttacher(mockConfig); + + it('should return a new plugin instance', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + }); + + const newPlugin = attacher(plugin); + expect(newPlugin).not.toBe(plugin); + await expect(getInfo(newPlugin)).resolves.toEqual({}); + }); + + it('should return non-plugin features unchanged', () => { + const nonPluginFeature = { + type: 'not-a-plugin', + } as unknown as FrontendFeature; + + expect(attacher(nonPluginFeature)).toBe(nonPluginFeature); + }); + + it('should resolve plugin info from package.json and config overrides', async () => { + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'other-tester', + info: { + packageJson: async () => ({ + name: '@test/package', + version: '1.0.0', + description: 'Original description', + homepage: 'https://homepage.com', + repository: { + url: 'https://github.com/test/project', + directory: 'packages/test', + }, + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@test/package', + version: '1.0.0', + description: 'Package name matched', + links: [ + { + title: 'Homepage', + url: 'https://homepage.com', + }, + { + title: 'Repository', + url: 'https://github.com/test/project/tree/master/packages/test', + }, + ], + }); + + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'info-tester', + info: { + packageJson: async () => ({ + name: '@other/package', + description: 'Original description', + homepage: 'https://homepage.com', + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@other/package', + description: 'Overridden description', + links: [ + { + title: 'Custom Link', + url: 'https://example.com', + }, + ], + }); + + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'not-info-tester', + info: { + packageJson: async () => ({ + name: '@other/package', + description: 'Original description', + repository: { + url: 'http://example.com', + directory: 'packages/test', + }, + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@other/package', + description: 'Overridden description', + ownerEntityRefs: ['group:default/test-group'], + links: [ + { + title: 'Repository', + url: 'http://example.com/', + }, + ], + }); + }); + }); + + describe('with custom resolver', () => { + const plugin = createFrontendPlugin({ + pluginId: 'custom-resolver', + info: { + packageJson: async () => ({ + name: '@test/resolver', + version: '1.0.0', + }), + manifest: async () => ({ + metadata: { + links: [{ title: 'Metadata link', url: 'https://example.com' }], + }, + }), + }, + }); + + it('should use the default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, async ctx => + ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }), + ); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + packageName: '@test/resolver', + version: '1.0.0', + links: [ + { + title: 'Metadata link', + url: 'https://example.com', + }, + ], + }); + }); + + it('should override info sources passed to default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, ctx => + ctx.defaultResolver({ + packageJson: { + name: '@test/resolver-other', + version: '2.0.0', + }, + manifest: { + metadata: { + links: [{ title: 'Other link', url: 'https://example.com' }], + }, + spec: { + owner: 'test-group', + }, + }, + }), + ); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + packageName: '@test/resolver-other', + version: '2.0.0', + links: [ + { + title: 'Other link', + url: 'https://example.com', + }, + ], + ownerEntityRefs: ['group:default/test-group'], + }); + }); + + it('should use a completely custom resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, async () => ({ + info: { version: '0.1.0' }, + })); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + version: '0.1.0', + }); + }); + + it('should handle unexpected input from the default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, ctx => + ctx.defaultResolver({ + packageJson: { + name: null, + version: {}, + }, + manifest: { + metadata: { + links: 'not an array', + }, + spec: [], + }, + }), + ); + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + version: '[object Object]', + }); + }); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts new file mode 100644 index 0000000000..27a6ca73ac --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -0,0 +1,247 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi } from '@backstage/core-plugin-api'; +import { + FrontendFeature, + FrontendPluginInfo, +} from '@backstage/frontend-plugin-api'; +import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { JsonObject, JsonValue } from '@backstage/types'; +import once from 'lodash/once'; +// Avoid full dependency on catalog-model +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + parseEntityRef, + stringifyEntityRef, +} from '../../../catalog-model/src/entity/ref'; + +/** + * A function that resolves plugin info from a plugin manifest and package.json. + * + * @public + */ +export type FrontendPluginInfoResolver = (ctx: { + packageJson(): Promise; + manifest(): Promise; + defaultResolver(sources: { + packageJson: JsonObject | undefined; + manifest: JsonObject | undefined; + }): Promise<{ info: FrontendPluginInfo }>; +}) => Promise<{ info: FrontendPluginInfo }>; + +export function createPluginInfoAttacher( + config: ConfigApi, + infoResolver: FrontendPluginInfoResolver = async ctx => + ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }), +): (feature: FrontendFeature) => FrontendFeature { + const applyInfoOverrides = createPluginInfoOverrider(config); + + return (feature: FrontendFeature) => { + if (!OpaqueFrontendPlugin.isType(feature)) { + return feature; + } + + const plugin = OpaqueFrontendPlugin.toInternal(feature); + + return { + ...plugin, + info: once(async () => { + const manifestLoader = plugin.infoOptions?.manifest; + const packageJsonLoader = plugin.infoOptions?.packageJson; + + const { info: resolvedInfo } = await infoResolver({ + manifest: async () => manifestLoader?.(), + packageJson: async () => packageJsonLoader?.(), + defaultResolver: async sources => ({ + info: { + ...resolvePackageInfo(sources.packageJson), + ...resolveManifestInfo(sources.manifest), + }, + }), + }); + + const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo); + return normalizePluginInfo(infoWithOverrides); + }), + }; + }; +} + +function normalizePluginInfo(info: FrontendPluginInfo) { + return { + ...info, + ownerEntityRefs: info.ownerEntityRefs?.map(ref => + stringifyEntityRef( + parseEntityRef(ref, { + defaultKind: 'group', + }), + ), + ), + }; +} + +function createPluginInfoOverrider(config: ConfigApi) { + const overrideConfigs = + config.getOptionalConfigArray('app.pluginOverrides') ?? []; + + const overrideMatchers = overrideConfigs.map(overrideConfig => { + const pluginIdMatcher = makeStringMatcher( + overrideConfig.getOptionalString('match.pluginId'), + ); + const packageNameMatcher = makeStringMatcher( + overrideConfig.getOptionalString('match.packageName'), + ); + const description = overrideConfig.getOptionalString('info.description'); + const ownerEntityRefs = overrideConfig.getOptionalStringArray( + 'info.ownerEntityRefs', + ); + const links = overrideConfig + .getOptionalConfigArray('info.links') + ?.map(linkConfig => ({ + title: linkConfig.getString('title'), + url: linkConfig.getString('url'), + })); + + return { + test(pluginId: string, packageName?: string) { + return packageNameMatcher(packageName) && pluginIdMatcher(pluginId); + }, + info: { + description, + ownerEntityRefs, + links, + }, + }; + }); + + return (pluginId: string, info: FrontendPluginInfo) => { + const { packageName } = info; + for (const matcher of overrideMatchers) { + if (matcher.test(pluginId, packageName)) { + if (matcher.info.description) { + info.description = matcher.info.description; + } + if (matcher.info.ownerEntityRefs) { + info.ownerEntityRefs = matcher.info.ownerEntityRefs; + } + if (matcher.info.links) { + info.links = matcher.info.links; + } + } + } + return info; + }; +} + +function resolveManifestInfo(manifest?: JsonValue) { + if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) { + return undefined; + } + + const info: FrontendPluginInfo = {}; + + if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') { + info.ownerEntityRefs = [ + stringifyEntityRef( + parseEntityRef(manifest.spec.owner, { + defaultKind: 'group', + defaultNamespace: manifest.metadata.namespace?.toString(), + }), + ), + ]; + } + + if (Array.isArray(manifest.metadata.links)) { + info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({ + title: String(link.title), + url: String(link.url), + })); + } + + return info; +} + +function resolvePackageInfo(packageJson?: JsonObject) { + if (!packageJson) { + return undefined; + } + + const info: FrontendPluginInfo = { + packageName: packageJson?.name?.toString(), + version: packageJson?.version?.toString(), + description: packageJson?.description?.toString(), + }; + + const links: { title: string; url: string }[] = []; + + if (typeof packageJson.homepage === 'string') { + links.push({ + title: 'Homepage', + url: packageJson.homepage, + }); + } + + if ( + isJsonObject(packageJson.repository) && + typeof packageJson.repository?.url === 'string' + ) { + try { + const url = new URL(packageJson.repository?.url); + if (url.protocol === 'http:' || url.protocol === 'https:') { + // TODO(Rugvip): Support more variants + if ( + url.hostname === 'github.com' && + typeof packageJson.repository.directory === 'string' + ) { + const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`; + url.pathname = path.replaceAll('//', '/'); + } + + links.push({ + title: 'Repository', + url: url.toString(), + }); + } + } catch { + /* ignored */ + } + } + + if (links.length > 0) { + info.links = links; + } + return info; +} + +function makeStringMatcher(pattern: string | undefined) { + if (!pattern) { + return () => true; + } + if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) { + const regex = new RegExp(pattern.slice(1, -1)); + return (str?: string) => (str ? regex.test(str) : false); + } + + return (str?: string) => str === pattern; +} + +function isJsonObject(value?: JsonValue): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 00551e16e3..7c6bb7958b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -671,4 +671,123 @@ describe('createSpecializedApp', () => { expect(render(root).container.textContent).toBe('1-2-test-1-2'); }); + + describe('plugin info', () => { + const testExtension = createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: () => [coreExtensionData.reactElement(
Test
)], + }); + + it('should throw unless accessed via an app', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + extensions: [testExtension], + }); + + const errorMsg = + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app"; + await expect(plugin.info()).rejects.toThrow(errorMsg); + + const app = createSpecializedApp({ features: [plugin] }); + + await expect(plugin.info()).rejects.toThrow(errorMsg); + + const installedPlugin = app.tree.nodes.get('test')?.spec.source; + expect(installedPlugin).toBeDefined(); + const info = await installedPlugin?.info(); + expect(info).toEqual({}); + }); + + it('should forward plugin info', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ features: [plugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toMatchObject({ + packageName: '@backstage/frontend-app-api', + }); + }); + + it('should allow overriding plugin info per plugin', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const overriddenPlugin = plugin.withOverrides({ + extensions: [], + info: { + packageJson: () => Promise.resolve({ name: 'test-override' }), + }, + }); + + const app = createSpecializedApp({ features: [overriddenPlugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toMatchObject({ + packageName: 'test-override', + }); + }); + + it('should merge with plugin info from manifest', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + manifest: async () => ({ + metadata: { + links: [{ title: 'Example', url: 'https://example.com' }], + }, + spec: { + owner: 'cubic-belugas', + }, + }), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ features: [plugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toEqual({ + packageName: '@backstage/frontend-app-api', + version: expect.any(String), + links: [{ title: 'Example', url: 'https://example.com' }], + ownerEntityRefs: ['group:default/cubic-belugas'], + }); + }); + + it('should allow overriding of the plugin info resolver', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ + features: [plugin], + async pluginInfoResolver(ctx) { + const { info } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }); + return { info: { packageName: `decorated:${info.packageName}` } }; + }, + }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toEqual({ + packageName: 'decorated:@backstage/frontend-app-api', + }); + }); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 5f6692d600..51b0f14250 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -31,6 +31,7 @@ import { routeResolutionApiRef, AppNode, ExtensionFactoryMiddleware, + FrontendFeature, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; import { BackstageRouteObject } from '../routing/types'; -import { FrontendFeature, RouteInfo } from './types'; +import { RouteInfo } from './types'; import { matchRoutes } from 'react-router-dom'; +import { + createPluginInfoAttacher, + FrontendPluginInfoResolver, +} from './createPluginInfoAttacher'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -209,9 +214,12 @@ export function createSpecializedApp(options?: { | ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[]; flags?: { allowUnknownExtensionConfig?: boolean }; + pluginInfoResolver?: FrontendPluginInfoResolver; }): { apis: ApiHolder; tree: AppTree } { const config = options?.config ?? new ConfigReader({}, 'empty-config'); - const features = deduplicateFeatures(options?.features ?? []); + const features = deduplicateFeatures(options?.features ?? []).map( + createPluginInfoAttacher(config, options?.pluginInfoResolver), + ); const tree = resolveAppTree( 'root', diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 4ecce3f184..fc52dcb7a1 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,4 +15,5 @@ */ export { createSpecializedApp } from './createSpecializedApp'; +export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher'; export * from './types';