diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd5c04..7a3c4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@red-hat-developer-hub/cli` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.11.2 - 2026-07-09 + +### Fixed + +- Added missing `backstage.features` field to generated `dist-dynamic/package.json` files in case of standard Module Federation asset generation. + ## 1.11.0 - 2026-05-08 ### Fixed diff --git a/e2e-tests/community-plugins-build-package.test.ts b/e2e-tests/community-plugins-build-package.test.ts index 9bd8227..220b14c 100644 --- a/e2e-tests/community-plugins-build-package.test.ts +++ b/e2e-tests/community-plugins-build-package.test.ts @@ -103,6 +103,9 @@ describe('export and package backstage-community plugin', () => { const packageJsonPath = path.join(getFullPluginPath(), 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); const role = packageJson.backstage?.role; + const expectedFeatures = expect.objectContaining({ + './alpha': '@backstage/FrontendPlugin', + }); if (role === 'frontend-plugin') { // eslint-disable-next-line jest/no-conditional-expect expect( @@ -113,6 +116,15 @@ describe('export and package backstage-community plugin', () => { ), ), ).toEqual(true); + + const distDynamicPkg = JSON.parse( + fs.readFileSync( + path.join(getFullPluginPath(), 'dist-dynamic/package.json'), + 'utf-8', + ), + ); + // eslint-disable-next-line jest/no-conditional-expect + expect(distDynamicPkg.backstage?.features).toEqual(expectedFeatures); } }); diff --git a/e2e-tests/rhdh-plugins-build-package.test.ts b/e2e-tests/rhdh-plugins-build-package.test.ts index 84ef47b..f0af0c5 100644 --- a/e2e-tests/rhdh-plugins-build-package.test.ts +++ b/e2e-tests/rhdh-plugins-build-package.test.ts @@ -103,6 +103,9 @@ describe('export and package rhdh-plugins scorecard workspace plugin', () => { const packageJsonPath = path.join(getFullPluginPath(), 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); const role = packageJson.backstage?.role; + const expectedFeatures = expect.objectContaining({ + './alpha': '@backstage/FrontendPlugin', + }); if (role === 'frontend-plugin') { // eslint-disable-next-line jest/no-conditional-expect expect( @@ -113,6 +116,15 @@ describe('export and package rhdh-plugins scorecard workspace plugin', () => { ), ), ).toEqual(true); + + const distDynamicPkg = JSON.parse( + fs.readFileSync( + path.join(getFullPluginPath(), 'dist-dynamic/package.json'), + 'utf-8', + ), + ); + // eslint-disable-next-line jest/no-conditional-expect + expect(distDynamicPkg.backstage?.features).toEqual(expectedFeatures); } }); diff --git a/package.json b/package.json index a0535c3..9d8d40d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@red-hat-developer-hub/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "1.11.1", + "version": "1.11.2", "publishConfig": { "access": "public" }, @@ -127,7 +127,8 @@ "type-fest": "4.20.1" }, "peerDependencies": { - "@microsoft/api-extractor": "^7.21.2" + "@microsoft/api-extractor": "^7.21.2", + "ts-morph": "*" }, "peerDependenciesMeta": { "@microsoft/api-extractor": { diff --git a/scripts/backstage-types-config.json b/scripts/backstage-types-config.json index c216a95..6f5e7b9 100644 --- a/scripts/backstage-types-config.json +++ b/scripts/backstage-types-config.json @@ -7,6 +7,14 @@ { "name": "@backstage/cli-module-build/dist/lib/buildBackend.cjs.js", "types": "dist-types/packages/cli-module-build/src/lib/buildBackend.d.ts" + }, + { + "name": "@backstage/cli-module-build/dist/lib/typeDistProject.cjs.js", + "types": "dist-types/packages/cli-module-build/src/lib/typeDistProject.d.ts" + }, + { + "name": "@backstage/cli-module-build/dist/lib/entryPoints.cjs.js", + "types": "dist-types/packages/cli-module-build/src/lib/entryPoints.d.ts" } ] } diff --git a/src/commands/export-dynamic-plugin/features.ts b/src/commands/export-dynamic-plugin/features.ts new file mode 100644 index 0000000..da6753a --- /dev/null +++ b/src/commands/export-dynamic-plugin/features.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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 { + BackstagePackageFeatureType, + BackstagePackageJson, +} from '@backstage/cli-node'; +import { readEntryPoints } from '@backstage/cli-module-build/dist/lib/entryPoints.cjs.js'; +import { + createTypeDistProject, + getEntryPointDefaultFeatureType, +} from '@backstage/cli-module-build/dist/lib/typeDistProject.cjs.js'; + +import chalk from 'chalk'; + +import { Task } from '../../lib/tasks'; + +/** + * Detects backstage feature types for all entry points in a package by + * analyzing the default export's $$type using ts-morph type resolution. + * + * Mirrors the feature detection in upstream Backstage's `productionPack` + * (packages/cli-module-build/src/lib/packager/productionPack.ts), which + * populates `backstage.features` in package.json during `backstage-cli pack`. + * Since rhdh-cli's forked `productionPack` predates that addition, we + * perform the same detection here as a separate step. + * + * Returns a map of entry point mount to feature type, or undefined if + * no features were detected. + */ +export async function detectBackstageFeatures( + originalPkg: BackstagePackageJson, + packageDir: string, +): Promise | undefined> { + const role = originalPkg.backstage?.role; + if (!role) { + return undefined; + } + + const project = await createTypeDistProject(); + const entryPoints = readEntryPoints(originalPkg); + const features: Record = {}; + + for (const ep of entryPoints) { + if (ep.mount === './package.json') { + continue; + } + + try { + const featureType = getEntryPointDefaultFeatureType( + role, + packageDir, + project, + ep.path, + ); + + if (featureType) { + features[ep.mount] = featureType; + Task.log( + ` detected backstage feature: ${chalk.cyan(ep.mount)} => ${chalk.green(featureType)}`, + ); + } + } catch (error) { + Task.log( + chalk.yellow( + `Failed to detect backstage feature type for entry point ${chalk.cyan(ep.mount)}: ${error}`, + ), + ); + } + } + + return Object.keys(features).length > 0 ? features : undefined; +} diff --git a/src/commands/export-dynamic-plugin/frontend.ts b/src/commands/export-dynamic-plugin/frontend.ts index ca7b2e9..a1aa95b 100644 --- a/src/commands/export-dynamic-plugin/frontend.ts +++ b/src/commands/export-dynamic-plugin/frontend.ts @@ -29,6 +29,7 @@ import { productionPack } from '../../lib/packager/productionPack'; import { paths } from '../../lib/paths'; import { Task } from '../../lib/tasks'; import { customizeForDynamicUse } from './backend'; +import { detectBackstageFeatures } from './features'; function isTruthyCiEnv(value: string | undefined): boolean { if (value === undefined) { @@ -42,12 +43,8 @@ export async function frontend( _: PackageRoleInfo, opts: OptionValues, ): Promise { - const { - name, - version, - scalprum: scalprumInline, - files, - } = await fs.readJson(paths.resolveTarget('package.json')); + const originalPkg = await fs.readJson(paths.resolveTarget('package.json')); + const { name, version, scalprum: scalprumInline, files } = originalPkg; if (!opts.generateScalprumAssets && !opts.generateModuleFederationAssets) { throw new Error( @@ -126,6 +123,10 @@ export async function frontend( ) { files.push('dist-scalprum'); } + const detectedFeatures = opts.generateModuleFederationAssets + ? await detectBackstageFeatures(originalPkg, paths.targetDir) + : undefined; + const monoRepoPackages = await getPackages(paths.targetDir); await customizeForDynamicUse({ embedded: [], @@ -141,6 +142,12 @@ export async function frontend( scripts: {}, files, }, + after: detectedFeatures + ? pkg => { + pkg.backstage = pkg.backstage ?? {}; + pkg.backstage.features = detectedFeatures; + } + : undefined, })(path.resolve(target, 'package.json')); if (opts.generateScalprumAssets) { diff --git a/src/generated/backstage-cli-types.d.ts b/src/generated/backstage-cli-types.d.ts index 7100ae2..f85482f 100644 --- a/src/generated/backstage-cli-types.d.ts +++ b/src/generated/backstage-cli-types.d.ts @@ -1,14 +1,13 @@ /* * Auto-generated TypeScript declarations for @backstage/cli-module-build * Generated from @backstage/cli version: 0.36.0 - * Generated on: 2026-04-08T00:00:00.000Z + * Generated on: 2026-07-09T15:52:41.408Z * * DO NOT EDIT THIS FILE MANUALLY - it will be overwritten * Run 'yarn generate-types' to regenerate * - * These types are extracted from the official Backstage monorepo TypeScript - * sources (packages/cli-module-build) via `yarn tsc` at the commit matching - * the pinned @backstage/cli version. + * Types are extracted from packages/cli-module-build in the Backstage monorepo + * (same commit as the pinned @backstage/cli), via root `yarn tsc`. */ declare module '@backstage/cli-module-build/dist/lib/buildFrontend.cjs.js' { @@ -37,3 +36,33 @@ declare module '@backstage/cli-module-build/dist/lib/buildBackend.cjs.js' { ): Promise; export {}; } + +declare module '@backstage/cli-module-build/dist/lib/typeDistProject.cjs.js' { + import { + BackstagePackageFeatureType, + PackageRole, + } from '@backstage/cli-node'; + import { Project } from 'ts-morph'; + + export declare const createTypeDistProject: () => Promise; + export declare const getEntryPointDefaultFeatureType: ( + role: PackageRole, + packageDir: string, + project: Project, + entryPoint: string, + ) => BackstagePackageFeatureType | null; +} + +declare module '@backstage/cli-module-build/dist/lib/entryPoints.cjs.js' { + import { BackstagePackageJson } from '@backstage/cli-node'; + + export interface EntryPoint { + mount: string; + path: string; + name: string; + ext: string; + } + export declare function readEntryPoints( + pkg: BackstagePackageJson, + ): Array; +} diff --git a/yarn.lock b/yarn.lock index aafdf2c..7fe231a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4982,6 +4982,7 @@ __metadata: yn: ^4.0.0 peerDependencies: "@microsoft/api-extractor": ^7.21.2 + ts-morph: "*" peerDependenciesMeta: "@microsoft/api-extractor": optional: true