diff --git a/.changeset/brave-pens-argue.md b/.changeset/brave-pens-argue.md new file mode 100644 index 0000000000..2ae66c29cf --- /dev/null +++ b/.changeset/brave-pens-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Fixed `resolvePackagePath` resolution for bundled dynamic plugins. When a plugin bundles its own copy of `@backstage/backend-plugin-api` inside `node_modules`, the `CommonJSModuleLoader` fallback now correctly resolves the plugin's `package.json` by name. Previously the fallback only applied when the resolution originated from the host application; it now also applies when originating from a bundled dependency, which is the case for plugins produced by the `backstage-cli package bundle` command. diff --git a/.changeset/cli-package-bundle.md b/.changeset/cli-package-bundle.md new file mode 100644 index 0000000000..602d631fd1 --- /dev/null +++ b/.changeset/cli-package-bundle.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-build': minor +--- + +Added `package bundle` command to create self-contained plugin bundles for dynamic loading, to be used by the `backend-dynamic-feature-service`. Supports backend and frontend plugins, with optional `--pre-packed-dir` for batch bundling from a pre-built workspace. diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 085175c958..2ed0e91891 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -204,6 +204,129 @@ Options: --module-federation Build a package as a module federation remote. Applies to frontend plugin packages only. ``` +## package bundle + +:::caution Experimental +This command is experimental and may receive breaking changes in future releases +without a deprecation period. It is hidden from the main `--help` output. +::: + +Bundle a plugin for dynamic loading. This creates a self-contained plugin +package that can be deployed independently and loaded dynamically by a Backstage +application. Supports both backend and frontend plugins. + +Unlike regular builds, the bundle command: + +- Creates a fully self-contained plugin deliverable + +- Produces module federation assets (frontend) or includes plugin dependencies in the plugin's private `node_modules`, building and packing (with `yarn pack`) the local `workspace:^` dependencies first (backend). +- Generates a config schema from plugin-related packages only. +- Validates that the plugin exports valid dynamic loading entry points (backend only) + +### Usage + +```bash +# Bundle the current package (output: ./bundle/) +yarn backstage-cli package bundle + +# Bundle to a specific directory (output: ../dynamic-plugins//) +yarn backstage-cli package bundle --output-destination ../dynamic-plugins + +# Override the bundle subdirectory name +yarn backstage-cli package bundle --output-name my-plugin-bundle + +# Clean output before bundling +yarn backstage-cli package bundle --clean + +# Skip building for the plugin and its local dependencies +yarn backstage-cli package bundle --no-build + +# Skip dependency installation and entrypoint validation +yarn backstage-cli package bundle --no-install + +# Stream detailed output from build, pack, and install steps +yarn backstage-cli package bundle --verbose + +# Use a pre-built dist workspace for batch bundling. +# First, create the workspace with: +# backstage-cli build-workspace [packages...] --alwaysPack +# Then pass as --pre-packed-dir: +yarn backstage-cli package bundle --pre-packed-dir ../dist-workspace +``` + +### Options + +```text +Usage: backstage-cli package bundle [options] + +Bundle a plugin for dynamic loading + +Options: + --output-destination Directory in which the bundle subdirectory is created. + Defaults to the current package directory. + --output-name Name of the bundle subdirectory. Defaults to "bundle" when + output stays in the package directory, or to the mangled + package name (e.g. myorg-plugin-foo) when + --output-destination is specified. + --clean Clean the output directory before bundling + --no-build Skip building packages (assumes they are already built) + --no-install Skip dependency installation and entrypoint validation. + --verbose Stream detailed output from internal steps (build, pack, + install) to the console. Without this flag, output is + captured to per-step log files and only shown on error. + --pre-packed-dir Path to a pre-built dist workspace (from + build-workspace --alwaysPack). Skips local dependency + packing and uses pre-packed packages directly. For frontend + plugins, this also enables yarn.lock generation for SBOM. +``` + +### Output Contract + +The bundle output is a directory that can be deployed as a standalone unit. +Consumers of the bundle (such as `@backstage/backend-dynamic-feature-service` +or `@backstage/frontend-dynamic-feature-loader`) can rely on the following +guarantees: + +**All bundles:** + +- A `package.json` at the bundle root with entry points configured for dynamic + loading. The `backstage.role` and `files` fields are preserved from the source package. +- A `dist/` directory containing the built plugin code. +- A `dist/.config-schema.json` file (when any config schemas apply) containing + gathered schemas from the plugin, its local workspace dependencies, and + third-party dependencies. Schemas from unrelated Backstage packages are excluded. +- No `scripts` or `devDependencies` in `package.json`. + +**Backend plugins** (`backend-plugin`, `backend-plugin-module`): + +- A `node_modules/` directory with all production dependencies (including local + workspace dependencies), pinned to their exact versions from the source lockfile. +- `bundleDependencies` is set to `true` in `package.json`. + +**Frontend plugins** (`frontend-plugin`, `frontend-plugin-module`): + +- `main` points to `dist/remoteEntry.js` (the Module Federation remote entry). +- `types` points to `dist/@mf-types/index.d.ts` when type declarations are + available. +- No embedded `node_modules/` directory. + +### Environment Variables + +The bundle command supports the same environment variables as the Backstage yarn plugin +for resolving `backstage:^` version specifiers: + +- `BACKSTAGE_MANIFEST_FILE`: Path to a local manifest file (for offline usage) +- `BACKSTAGE_VERSIONS_BASE_URL`: Custom base URL for fetching release manifests + +### Supported Package Roles + +The bundle command supports packages with the following roles: + +- `backend-plugin` +- `backend-plugin-module` +- `frontend-plugin` +- `frontend-plugin-module` + ## package lint Lint a package. In addition to the default `eslint` behavior, this command will @@ -414,9 +537,17 @@ package. This essentially calls `yarn pack` in each included package and unpacks the resulting archive in the target `workspace-dir`. ```text -Usage: backstage-cli build-workspace [options] +Usage: backstage-cli build-workspace [options] [packages...] + +Options: + --alwaysPack Force workspace output to be a result of running `yarn pack` on + each package (warning: very slow) ``` +When `--alwaysPack` is used, the output directory can be passed to +`backstage-cli package bundle --pre-packed-dir` to speed up batch bundling of +multiple plugins from the same monorepo. + ## create-github-app Creates a GitHub App in your GitHub organization. This is an alternative to diff --git a/packages/backend-dynamic-feature-service/README.md b/packages/backend-dynamic-feature-service/README.md index 8122810f96..914df15f65 100644 --- a/packages/backend-dynamic-feature-service/README.md +++ b/packages/backend-dynamic-feature-service/README.md @@ -97,7 +97,7 @@ Since this service only handles loading, you would choose a packaging approach b **When to use:** Plugin only uses dependencies that are already provided by the main Backstage application. -**How to apply:** +**How to use:** ```bash cd my-backstage-plugin @@ -117,7 +117,7 @@ tar -xzf package.tgz -C /path/to/dynamic-plugins-root/my-backstage-plugin --stri **When to use:** Plugin has private dependencies not available in the main Backstage application. -**How to apply:** +**How to use:** ```bash # Package the plugin @@ -136,35 +136,47 @@ yarn install # Installs all the plugin's dependencies **Example scenario:** Plugin needs `axios@1.4.0` which isn't available in the main application. -### 3. Custom packaging CLI tool +### 3. Dedicated bundling CLI command -**When to use:** When you want to produce self-contained dynamic plugin packages that can be directly extracted without any post-action, and systematically use the core `@backstage` dependencies provided by the Backstage application. +**When to use:** -**What a packaging CLI needs to do:** +- When you want to produce self-contained dynamic plugin packages that can be directly extracted and loaded without any post-action, +- especially when your plugin depends on other packages in the same monorepo. -1. **Analyze plugin dependencies** - Identify which are Backstage core vs private dependencies -2. **Create distribution package** - Generate a new directory with modified structure: - - Move `@backstage/*` packages from `dependencies` to `peerDependencies` in package.json - - Keep only private dependencies in the `dependencies` section - - Keep the built JavaScript code unchanged - - Include only the filtered private dependencies in `node_modules` -3. **Result** - A self-contained package that uses the main app's `@backstage/*` packages but includes its own private dependencies +**How to use:** -**Benefits:** - -- Systematic use of main application's `@backstage/*` packages (no version conflicts), enabling the future implementation of `@backstage` dependency version checking at start time -- Self-contained packages with only necessary private dependencies -- No post-installation steps required (extract and run) -- Consistent dependency structure across all dynamic plugins -- Production-ready distribution format - -**Example implementation:** The [`@red-hat-developer-hub/cli`](https://github.com/redhat-developer/rhdh-cli) tool implements this approach: +The [`backstage-cli package bundle`](../cli/cli-report.md) command automates the required steps. Run it from within a plugin directory: ```bash cd my-backstage-plugin -npx @red-hat-developer-hub/cli@latest plugin export -# Creates a self-contained package with embedded dependencies in the `/dist-dynamic` sub-folder - -# Deploy the generated package -cp -r dist-dynamic /path/to/dynamic-plugins-root/my-backstage-plugin +yarn backstage-cli package bundle --output-destination /path/to/dynamic-plugins-root +# Creates a self-contained bundle in the /path/to/dynamic-plugins-root/my-backstage-plugin/ sub-folder ``` + +**Batch bundling:** When bundling many plugins from the same monorepo, use `--pre-packed-dir` to avoid redundant work: + +```bash +# First, build a shared dist workspace +backstage-cli build-workspace dist-workspace --alwaysPack ...plugin-packages + +# Then bundle each plugin using the pre-packed output +cd plugins/my-backstage-plugin +backstage-cli package bundle --pre-packed-dir ../../dist-workspace +``` + +See the full list of options in the [CLI reference](../cli/cli-report.md). + +**What the command does:** + +1. **Builds the plugin** — Produces CJS output for the backend plugin and its transitively-required monorepo packages (`*-node` or `*-common`) +2. **Packs local packages** — Resolves `workspace:^` and `backstage:^` dependencies on both the main plugin package and its transitively-required monorepo packages (`*-node` or `*-common`) +3. **Installs private dependencies** — Seeds a lockfile from the plugin source monorepo and prunes it, then installs a private `node_modules` containing all required dependencies +4. **Collects configuration schemas** — Gathers plugin config schemas and writes them to `dist/.config-schema.json` so they are available for validation at load time + +**Benefits:** + +- Self-contained packages with all necessary dependencies +- No post-installation steps required (extract and run) +- Consistent dependency structure across all dynamic plugins +- Automatic configuration schema collection +- Production-ready distribution format diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js new file mode 100644 index 0000000000..f3671bc0a7 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js @@ -0,0 +1,28 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +// This require resolves to the bundled proxy copy inside this plugin's +// own node_modules/@backstage/backend-plugin-api, NOT the host's copy. +var backendPluginApi = require('@backstage/backend-plugin-api'); + +// Triggers the _resolveFilename fallback: require.resolve runs from +// the bundled @backstage/backend-plugin-api whose mod.path is inside +// this plugin's node_modules. +var pkgDir = backendPluginApi.resolvePackagePath('plugin-test-backend-bundled'); + +const testBundledPlugin = backendPluginApi.createBackendPlugin({ + pluginId: "test-bundled", + register(env) { + env.registerInit({ + deps: { + logger: backendPluginApi.coreServices.rootLogger, + }, + async init({ logger }) { + logger.info("Bundled backend plugin loaded successfully"); + } + }); + } +}); + +exports.default = testBundledPlugin; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js new file mode 100644 index 0000000000..70e57950be --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js @@ -0,0 +1,22 @@ +'use strict'; + +// Proxy that simulates a bundled copy of @backstage/backend-plugin-api. +// Re-exports everything from the real workspace package, but provides a +// local resolvePackagePath so that require.resolve() runs from THIS +// file's context (mod.path inside node_modules/@backstage/backend-plugin-api). + +const nodePath = require('node:path'); +const real = require('../../../../../../../../../backend-plugin-api/src'); + +function resolvePackagePath(name) { + const args = Array.prototype.slice.call(arguments, 1); + const pkgJson = require.resolve(name + '/package.json'); + return nodePath.resolve.apply( + nodePath, + [nodePath.dirname(pkgJson)].concat(args), + ); +} + +module.exports = Object.assign({}, real, { + resolvePackagePath: resolvePackagePath, +}); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json new file mode 100644 index 0000000000..f487169afe --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json @@ -0,0 +1,6 @@ +{ + "name": "@backstage/backend-plugin-api", + "version": "0.0.0", + "description": "Proxy that re-exports the real @backstage/backend-plugin-api with a local resolvePackagePath, simulating a bundled copy.", + "main": "index.js" +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json new file mode 100644 index 0000000000..bb2dbf15a6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json @@ -0,0 +1,31 @@ +{ + "name": "plugin-test-backend-bundled-dynamic", + "version": "0.0.0", + "description": "A test dynamic backend plugin that bundles its own @backstage/backend-plugin-api.", + "backstage": { + "role": "backend-plugin", + "pluginId": "test-bundled", + "pluginPackages": [ + "plugin-test-backend-bundled" + ] + }, + "keywords": [ + "backstage", + "dynamic" + ], + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "default": "./dist/index.cjs.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs.js", + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-plugin-api": "0.0.0" + }, + "bundleDependencies": true +} diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts index ce15964c77..ac6c6ea510 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.test.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -465,6 +465,45 @@ Require stack: }); }); + it('should load a backend plugin that bundles its own @backstage/backend-plugin-api', async () => { + const dynamicPluginsLister = new DynamicPluginLister(); + const dynamicPluginsRootForBundled = resolvePath( + __dirname, + '__fixtures__/dynamic-plugins-root-for-bundled', + ); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootForBundled, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + }), + dynamicPluginsLister.feature(), + ], + }); + + expect(dynamicPluginsLister.loadedPlugins).toMatchObject([ + { + installer: { + kind: 'new', + }, + name: 'plugin-test-backend-bundled-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + ]); + }); + describe('module federation support', () => { const createRemoteProviderPlugin = ( provider: FrontendRemoteResolverProvider, diff --git a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts index 3cc640086f..8902decdfa 100644 --- a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts +++ b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts @@ -99,15 +99,21 @@ export class CommonJSModuleLoader implements ModuleLoader { ); } - // Are we trying to resolve a `package.json` from an originating module of the core backstage application - // (this is mostly done by calling `@backstage/backend-plugin-api/resolvePackagePath`). - const resolvingPackageJsonFromBackstageApplication = + // Is this a `resolvePackagePath` call from `@backstage/backend-plugin-api`? + // This covers both the host application's copy and a bundled copy living + // inside a dynamic plugin's own node_modules. + // The regex matches mod.path against the various ways the package can be resolved on disk + // (with optional subdirectory such as /src or /dist after the package name): + // - .../node_modules/@backstage/backend-plugin-api[/...] (npm-installed) + // - ...//node_modules/@backstage/backend-plugin-api[/...] (bundled) + // - .../packages/backend-plugin-api[/...] (symlinked workspace in monorepo) + const resolvingPackageJsonViaResolvePackagePath = request?.endsWith('/package.json') && - mod?.path && - !dynamicPluginsPaths.some(p => mod.path.startsWith(p)); + /[/\\](?:@backstage|packages)[/\\]backend-plugin-api(?:[/\\]|$)/.test( + mod?.path ?? '', + ); - // If not, we don't need the dedicated specific case below. - if (!resolvingPackageJsonFromBackstageApplication) { + if (!resolvingPackageJsonViaResolvePackagePath) { throw errorToThrow; } diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore new file mode 100644 index 0000000000..eb67c88369 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore @@ -0,0 +1 @@ +!dist* \ No newline at end of file diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json new file mode 100644 index 0000000000..5c2babcbc2 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/foo-node", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js new file mode 100644 index 0000000000..b76e7a9d93 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js @@ -0,0 +1 @@ +module.exports = { default: { $$type: '@backstage/BackendFeature' } }; diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json new file mode 100644 index 0000000000..53ea4fbdb8 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/plugin-foo-backend", + "version": "1.0.0", + "main": "dist/index.js", + "dependencies": { + "@scope/plugin-foo-common": "1.0.0" + } +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json new file mode 100644 index 0000000000..868fad5eaf --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/plugin-foo-common", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json new file mode 100644 index 0000000000..1ae64d251d --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/foo-web", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json new file mode 100644 index 0000000000..80abb7d0b8 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/plugin-foo-react", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json new file mode 100644 index 0000000000..e5be9d71e5 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/plugin-foo", + "version": "1.0.0", + "main": "dist/index.js", + "dependencies": { + "@scope/plugin-foo-react": "1.0.0" + } +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js @@ -0,0 +1 @@ + diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js new file mode 100644 index 0000000000..94f616153c --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js @@ -0,0 +1 @@ +// Module Federation remote entry diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json new file mode 100644 index 0000000000..96efb68a2f --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "@scope/plugin-foo", + "version": "1.0.0", + "backstage": { + "role": "frontend-plugin", + "pluginId": "foo" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "exports": { + ".": {}, + "./alpha": {}, + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ] + } + }, + "dependencies": { + "@backstage/catalog-model": "^1.7.6" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts new file mode 100644 index 0000000000..e512e4ba83 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export {}; diff --git a/packages/cli-module-build/src/commands/package/bundle/command.test.ts b/packages/cli-module-build/src/commands/package/bundle/command.test.ts new file mode 100644 index 0000000000..642e220224 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/command.test.ts @@ -0,0 +1,1126 @@ +/* + * 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. + */ + +/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect", "expectPathExists"] }] */ + +import { createMockDirectory } from '@backstage/backend-test-utils'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import os from 'node:os'; +import { join as joinPath } from 'node:path'; + +import { targetPaths } from '@backstage/cli-common'; +import { + bundleCommand, + filterBundleConfigSchemas, + postProcessBundlePackageJson, +} from './command'; + +const fixturesDir = joinPath(__dirname, '__fixtures__'); + +const mockCreateDistWorkspace = jest.fn(); +const mockPackToDirectory = jest.fn(); +const mockBuildFrontend = jest.fn(); +const mockRun = jest.fn(); +const mockRunOutput = jest.fn(); +const mockListTargetPackages = jest.fn(); +const mockLoadConfigSchema = jest.fn(); +const mockCreateRequire = jest.fn(); + +// Mock external dependencies + +jest.mock('@backstage/cli-common', () => ({ + ...jest.requireActual('@backstage/cli-common'), + targetPaths: { + dir: '', + rootDir: '', + resolve: jest.fn(), + }, + run: (...args: unknown[]) => mockRun(...args), + runOutput: (...args: unknown[]) => mockRunOutput(...args), +})); + +jest.mock('../../../lib/packager', () => ({ + ...jest.requireActual('../../../lib/packager'), + createDistWorkspace: (...args: unknown[]) => mockCreateDistWorkspace(...args), + packToDirectory: (...args: unknown[]) => mockPackToDirectory(...args), +})); + +jest.mock('../../../lib/buildFrontend', () => ({ + buildFrontend: (...args: unknown[]) => mockBuildFrontend(...args), +})); + +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + PackageGraph: Object.assign(actual.PackageGraph, { + listTargetPackages: (...args: unknown[]) => + mockListTargetPackages(...args), + }), + }; +}); + +jest.mock('@backstage/config-loader', () => ({ + loadConfigSchema: (...args: unknown[]) => mockLoadConfigSchema(...args), +})); + +jest.mock('node:module', () => ({ + ...jest.requireActual('node:module'), + createRequire: (...args: unknown[]) => mockCreateRequire(...args), +})); + +// Unit tests for exported pure functions (no mocks required) + +describe('postProcessBundlePackageJson', () => { + it('clears scripts and devDependencies', () => { + const pkg: Record = { + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.scripts).toEqual({}); + expect(pkg.devDependencies).toEqual({}); + }); + + it('sets bundleDependencies for backend plugins', () => { + const pkg: Record = {}; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.bundleDependencies).toBe(true); + }); + + it('does not set bundleDependencies for frontend plugins', () => { + const pkg: Record = {}; + postProcessBundlePackageJson(pkg, '/tmp', 'frontend', undefined, {}, true); + expect(pkg.bundleDependencies).toBeUndefined(); + }); + + it('sets MF entry points for frontend plugins', () => { + const pkg: Record = { + main: './dist/index.cjs.js', + exports: { '.': './dist/index.cjs.js' }, + module: './dist/index.esm.js', + typesVersions: { '*': { '*': ['dist/index.d.ts'] } }, + }; + postProcessBundlePackageJson(pkg, '/tmp', 'frontend', undefined, {}, false); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.exports).toBeUndefined(); + expect(pkg.module).toBeUndefined(); + expect(pkg.typesVersions).toBeUndefined(); + }); + + it('does not set MF entry points for backend plugins', () => { + const pkg: Record = { + main: './dist/index.cjs.js', + }; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.main).toBe('./dist/index.cjs.js'); + }); + + it('merges root resolutions and strips patch prefix', () => { + const pkg: Record = { + resolutions: { existing: '3.0.0' }, + }; + const rootResolutions = { + 'some-dep': '1.0.0', + 'patched-dep': 'patch:patched-dep@npm%3A2.0.0#./patch', + }; + postProcessBundlePackageJson( + pkg, + '/tmp', + 'backend', + rootResolutions, + {}, + true, + ); + expect(pkg.resolutions).toEqual({ + 'some-dep': '1.0.0', + 'patched-dep': '2.0.0', + existing: '3.0.0', + }); + }); + + it('does not merge resolutions when needsDependencies is false', () => { + const pkg: Record = {}; + postProcessBundlePackageJson( + pkg, + '/tmp', + 'backend', + { dep: '1.0.0' }, + {}, + false, + ); + expect(pkg.resolutions).toBeUndefined(); + }); +}); + +describe('filterBundleConfigSchemas', () => { + const mockDir = createMockDirectory(); + + beforeEach(() => { + mockCreateRequire.mockImplementation((p: string) => + jest + .requireActual('node:module') + .createRequire(p), + ); + }); + + afterEach(() => { + mockCreateRequire.mockReset(); + }); + + function writePluginTree( + pluginPkg: Record, + deps: Record> = {}, + ) { + const content: Record = { + 'package.json': JSON.stringify(pluginPkg), + }; + for (const [name, depPkg] of Object.entries(deps)) { + content[`node_modules/${name}/package.json`] = JSON.stringify(depPkg); + } + mockDir.setContent(content); + } + + function schemas(...names: string[]) { + return names.map(n => ({ packageName: n, value: {}, path: '' })); + } + + it('includes the plugin itself', () => { + writePluginTree({ name: '@scope/my-plugin', dependencies: {} }); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@other/unrelated'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual(['@scope/my-plugin']); + }); + + it('includes third-party deps without backstage metadata', () => { + writePluginTree( + { name: '@scope/my-plugin', dependencies: { lodash: '^4.0.0' } }, + { lodash: { name: 'lodash', version: '4.17.21' } }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', 'lodash'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toContain('lodash'); + }); + + it('includes same-pluginId libraries', () => { + writePluginTree( + { + name: '@scope/my-plugin', + backstage: { role: 'backend-plugin', pluginId: 'foo' }, + dependencies: { '@scope/my-lib': '^1.0.0' }, + }, + { + '@scope/my-lib': { + name: '@scope/my-lib', + backstage: { role: 'node-library', pluginId: 'foo' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/my-lib'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual([ + '@scope/my-plugin', + '@scope/my-lib', + ]); + }); + + it('excludes library with different pluginId', () => { + writePluginTree( + { + name: '@scope/my-plugin', + backstage: { role: 'backend-plugin', pluginId: 'foo' }, + dependencies: { '@scope/other-lib': '^1.0.0' }, + }, + { + '@scope/other-lib': { + name: '@scope/other-lib', + backstage: { role: 'node-library', pluginId: 'bar' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/other-lib'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual(['@scope/my-plugin']); + }); + + it('recursively includes depended plugin/module schemas', () => { + writePluginTree( + { + name: '@scope/my-plugin', + dependencies: { '@scope/my-module': '^1.0.0' }, + }, + { + '@scope/my-module': { + name: '@scope/my-module', + backstage: { role: 'backend-plugin-module' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/my-module'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual([ + '@scope/my-plugin', + '@scope/my-module', + ]); + }); +}); + +// Integration tests for the bundle command (require mocks) + +describe('bundle command', () => { + const mockDir = createMockDirectory(); + + const backendPluginDir = 'plugins/foo-backend'; + const backendPkg = { + name: '@scope/plugin-foo-backend', + version: '1.0.0', + backstage: { role: 'backend-plugin' }, + dependencies: {}, + }; + const backendMangledName = 'scope-plugin-foo-backend'; + + const frontendPluginDir = 'plugins/foo'; + const frontendPkg = { + name: '@scope/plugin-foo', + version: '1.0.0', + backstage: { role: 'frontend-plugin' }, + dependencies: {}, + }; + + const defaultRootPkg = { + name: 'root', + version: '1.0.0', + resolutions: { + 'some-dep': '1.0.0', + 'patched-dep': 'patch:patched-dep@npm%3A2.0.0#./patch', + }, + }; + + const defaultOpts = { + build: true, + install: true, + clean: false, + verbose: false, + outputDestination: undefined as string | undefined, + outputName: undefined as string | undefined, + prePackedDir: undefined as string | undefined, + }; + + function setupPlugin( + projectRelativeDir: string, + pkg: Record, + bundleName = 'bundle', + ) { + const pluginDir = joinPath(mockDir.path, projectRelativeDir); + const targetDir = joinPath(pluginDir, bundleName); + + mockDir.setContent({ + [joinPath(projectRelativeDir, 'package.json')]: JSON.stringify(pkg), + [joinPath(projectRelativeDir, 'yarn.lock')]: '# yarn.lock content', + 'package.json': JSON.stringify(defaultRootPkg), + 'yarn.lock': '# root yarn.lock', + 'tmp/.keep': '', + }); + targetPaths.dir = pluginDir; + targetPaths.rootDir = mockDir.path; + (targetPaths.resolve as jest.Mock).mockImplementation((...args: string[]) => + joinPath(targetPaths.dir, ...args), + ); + + return { pluginDir, targetDir, relDir: projectRelativeDir, bundleName }; + } + + function setupCreateDistWorkspaceMock( + pluginDir: string, + targets: { name: string; dir: string }[] = [], + invokeLogger = false, + ) { + const mainTarget = { name: backendPkg.name, dir: pluginDir }; + mockCreateDistWorkspace.mockImplementation(async (_pkgNames, opts) => { + if (invokeLogger && opts.logger) { + opts.logger.log(`Moving ${backendPkg.name} into dist workspace`); + opts.logger.warn('some dist workspace warning'); + } + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + opts.targetDir, + ); + return { targets: targets.length ? targets : [mainTarget] }; + }); + } + + function setupPackToDirectoryMock() { + mockPackToDirectory.mockImplementation( + async (opts: { targetDir: string; packageName: string }) => { + await fs.copy( + joinPath(fixturesDir, 'packed', 'frontend'), + opts.targetDir, + ); + const pkgPath = joinPath(opts.targetDir, 'package.json'); + const pkg = await fs.readJson(pkgPath); + pkg.name = opts.packageName; + await fs.writeJson(pkgPath, pkg); + }, + ); + } + + function setupRunMock() { + mockRun.mockImplementation( + ( + args: string[], + opts: { cwd: string; onStdout?: (d: Buffer) => void }, + ) => ({ + waitForExit: async () => { + if (!args.includes('update-lockfile')) { + await fs.ensureDir(joinPath(opts.cwd, 'node_modules')); + await fs.ensureDir(joinPath(opts.cwd, '.yarn')); + } + opts.onStdout?.(Buffer.from('mock yarn output\n')); + }, + }), + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(os, 'tmpdir').mockReturnValue(joinPath(mockDir.path, 'tmp')); + mockLoadConfigSchema.mockResolvedValue({ + serialize: () => ({ schemas: [] }), + }); + mockRunOutput.mockResolvedValue('/mock-cache'); + setupRunMock(); + mockCreateRequire.mockImplementation((path: string) => + jest.requireActual('node:module').createRequire(path), + ); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + // Clear native Node module cache for files under mockDir to prevent + // cross-test contamination when real createRequire loads dist/index.js. + const nativeModule = + jest.requireActual('node:module'); + const nativeRequire = nativeModule.createRequire(__filename); + for (const key of Object.keys(nativeRequire.cache ?? {})) { + if (key.includes(mockDir.path)) { + delete nativeRequire.cache![key]; + } + } + jest.restoreAllMocks(); + }); + + async function expectPathExists(parts: string[], exists: boolean) { + expect(await fs.pathExists(joinPath(...parts))).toBe(exists); + } + + describe('validation', () => { + it('throws when backstage.role is missing', async () => { + setupPlugin(backendPluginDir, { + name: '@scope/plugin-foo-backend', + version: '1.0.0', + }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'does not have a backstage.role defined in package.json', + ); + }); + + it('throws when role is invalid', async () => { + setupPlugin(backendPluginDir, { + ...backendPkg, + backstage: { role: 'backend' }, + }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + `only supports: ${chalk.cyan('backend-plugin')}, ${chalk.cyan( + 'backend-plugin-module', + )}, ${chalk.cyan('frontend-plugin')}, ${chalk.cyan( + 'frontend-plugin-module', + )}`, + ); + }); + + it('throws when bundled is true', async () => { + setupPlugin(backendPluginDir, { ...backendPkg, bundled: true }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'not compatible with dynamic plugin bundling', + ); + }); + }); + + describe('backend', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = setupPlugin(backendPluginDir, backendPkg); + }); + + describe('via createDistWorkspace', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should produce backend bundle when build=true', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(pkg.bundleDependencies).toBe(true); + await expectPathExists([ctx.targetDir, '.gitignore'], true); + await expectPathExists([ctx.targetDir, '.yarnrc.yml'], true); + expect(mockCreateDistWorkspace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ buildDependencies: true }), + ); + }); + + it.each(['null', 'undefined'])( + 'should not write yarnPath when yarn config returns "%s"', + async sentinel => { + mockRunOutput.mockImplementation( + (args: string[]): Promise => { + if (args.includes('yarnPath')) { + return Promise.resolve(sentinel); + } + return Promise.resolve('/mock-cache'); + }, + ); + await bundleCommand(defaultOpts); + const yarnrc = await fs.readFile( + joinPath(ctx.targetDir, '.yarnrc.yml'), + 'utf8', + ); + expect(yarnrc).not.toContain('yarnPath'); + expect(yarnrc).toContain('nodeLinker: node-modules'); + }, + ); + + it('should write yarnPath when yarn config returns a real path', async () => { + mockRunOutput.mockImplementation((args: string[]): Promise => { + if (args.includes('yarnPath')) { + return Promise.resolve('/home/user/.yarn/releases/yarn-3.8.1.cjs'); + } + return Promise.resolve('/mock-cache'); + }); + await bundleCommand(defaultOpts); + const yarnrc = await fs.readFile( + joinPath(ctx.targetDir, '.yarnrc.yml'), + 'utf8', + ); + expect(yarnrc).toContain( + 'yarnPath: /home/user/.yarn/releases/yarn-3.8.1.cjs', + ); + }); + + it('should pass buildDependencies=false when build=false', async () => { + await bundleCommand({ ...defaultOpts, build: false }); + expect(mockCreateDistWorkspace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ buildDependencies: false }), + ); + }); + + it('should produce backend bundle for backend-plugin-module role', async () => { + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + backstage: { role: 'backend-plugin-module' }, + }); + setupCreateDistWorkspaceMock(ctx.pluginDir); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(mockCreateDistWorkspace).toHaveBeenCalled(); + expect(mockPackToDirectory).not.toHaveBeenCalled(); + }); + + it('should assemble local deps into embedded/ and clean up .yarn', async () => { + const commonRelDir = 'plugins/foo-common'; + const commonDir = joinPath(mockDir.path, commonRelDir); + + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + dependencies: { '@scope/plugin-foo-common': 'workspace:^' }, + }); + setupCreateDistWorkspaceMock(ctx.pluginDir, [ + { name: backendPkg.name, dir: ctx.pluginDir }, + { name: '@scope/plugin-foo-common', dir: commonDir }, + ]); + + await bundleCommand(defaultOpts); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + await expectPathExists( + [ctx.targetDir, 'embedded', commonRelDir, 'package.json'], + true, + ); + expect(pkg.resolutions['@scope/plugin-foo-common']).toBe( + `file:./embedded/${commonRelDir}`, + ); + await expectPathExists([ctx.targetDir, '.yarn'], false); + }); + + it('should log formatted packing output when distLogger is invoked', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir, [], true); + await bundleCommand(defaultOpts); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`Packing`), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(backendPkg.name), + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('some dist workspace warning'), + ); + }); + }); + + describe('via createDistWorkspace - failure', () => { + it('should clean up temp dir and propagate error on createDistWorkspace failure', async () => { + mockCreateDistWorkspace.mockRejectedValue(new Error('pack failed')); + const tempBase = joinPath(mockDir.path, 'tmp'); + jest.spyOn(os, 'tmpdir').mockReturnValue(tempBase); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow('pack failed'); + + const entries = await fs.readdir(tempBase).catch(() => []); + expect( + entries.filter((e: string) => e.startsWith('bundle-workspace-')), + ).toHaveLength(0); + }); + }); + + describe('via --pre-packed-dir', () => { + beforeEach(() => { + mockCreateDistWorkspace.mockClear(); + }); + + it('should copy from pre-packed dir and assemble embedded', async () => { + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + prePackedPath, + ); + + mockListTargetPackages.mockResolvedValue([ + { packageJson: { name: backendPkg.name }, dir: ctx.pluginDir }, + ]); + + await bundleCommand({ ...defaultOpts, prePackedDir: prePackedPath }); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(pkg.bundleDependencies).toBe(true); + }); + + it('should print warning when package not found in pre-packed dir', async () => { + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + dependencies: { '@scope/other-dep': 'workspace:^' }, + }); + mockListTargetPackages.mockResolvedValue([ + { + packageJson: { + name: backendPkg.name, + version: '1.0.0', + dependencies: { '@scope/other-dep': 'workspace:^' }, + }, + dir: ctx.pluginDir, + }, + { + packageJson: { name: '@scope/other-dep', version: '1.0.0' }, + dir: joinPath(mockDir.path, 'packages/other-dep'), + }, + ]); + + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + prePackedPath, + ); + + await bundleCommand({ ...defaultOpts, prePackedDir: prePackedPath }); + + expect(console.warn).toHaveBeenCalledWith( + chalk.yellow( + ` Package ${chalk.cyan( + '@scope/other-dep', + )} not found in pre-packed dir (expected at ${chalk.cyan( + 'packages/other-dep', + )})`, + ), + ); + }); + }); + + describe('lockfile and install', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should seed lockfile from monorepo root when plugin has no yarn.lock', async () => { + mockDir.setContent({ + [joinPath(backendPluginDir, 'package.json')]: + JSON.stringify(backendPkg), + 'package.json': JSON.stringify(defaultRootPkg), + 'yarn.lock': '# root yarn.lock content', + 'tmp/.keep': '', + }); + + await bundleCommand(defaultOpts); + + const lockContent = await fs.readFile( + joinPath(ctx.targetDir, 'yarn.lock'), + 'utf8', + ); + expect(lockContent).toBe('# root yarn.lock content'); + }); + + it('throws when no yarn.lock exists', async () => { + mockDir.setContent({ + [joinPath(backendPluginDir, 'package.json')]: + JSON.stringify(backendPkg), + 'package.json': JSON.stringify(defaultRootPkg), + 'tmp/.keep': '', + }); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + `Could not find a ${chalk.cyan( + 'yarn.lock', + )} file in either the plugin directory or the monorepo root (${chalk.cyan( + mockDir.path, + )})`, + ); + }); + + it('should run prune and install in the bundle dir', async () => { + await bundleCommand(defaultOpts); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining([ + 'yarn', + 'install', + '--no-immutable', + '--mode', + 'update-lockfile', + ]), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['yarn', 'install', '--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + }); + + it('throws when backend plugin has no valid BackendFeature export', async () => { + mockCreateRequire.mockImplementation(() => + Object.assign(() => ({ default: {} }), { + resolve: (id: string) => { + if (id.includes('package.json')) { + return joinPath(targetPaths.dir, 'node_modules', id); + } + throw new Error(`Cannot find module '${id}'`); + }, + }), + ); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'Backend plugin is not valid for dynamic loading', + ); + }); + }); + + describe('--no-install', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should skip install and print warning', async () => { + await bundleCommand({ ...defaultOpts, install: false }); + + await expectPathExists([ctx.targetDir, 'package.json'], true); + await expectPathExists([ctx.targetDir, 'yarn.lock'], true); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['--mode', 'update-lockfile']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).not.toHaveBeenCalledWith( + expect.arrayContaining(['--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Skipping dependency installation'), + ); + }); + }); + + describe('options', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should remove target when clean=true', async () => { + mockDir.addContent({ + [joinPath(ctx.relDir, ctx.bundleName, 'existing-file')]: 'content', + }); + + await bundleCommand({ ...defaultOpts, clean: true }); + + await expectPathExists([ctx.targetDir, 'existing-file'], false); + await expectPathExists([ctx.targetDir, 'package.json'], true); + }); + + it('should write bundle to custom outputDestination with mangled name', async () => { + const customOutput = joinPath(mockDir.path, 'custom-output'); + await bundleCommand({ + ...defaultOpts, + outputDestination: customOutput, + }); + + await expectPathExists( + [customOutput, backendMangledName, 'package.json'], + true, + ); + }); + + it('should use "bundle" as default name when output stays in package dir', async () => { + await bundleCommand(defaultOpts); + const entries = await fs.readdir(ctx.pluginDir); + expect(entries).toContain('bundle'); + }); + + it('should use mangled package name when outputDestination is given', async () => { + const customOutput = joinPath(mockDir.path, 'custom-output'); + await bundleCommand({ + ...defaultOpts, + outputDestination: customOutput, + }); + const entries = await fs.readdir(customOutput); + expect(entries).toContain(backendMangledName); + }); + + it('should use explicit outputName when provided', async () => { + await bundleCommand({ ...defaultOpts, outputName: 'my-custom-bundle' }); + const entries = await fs.readdir(ctx.pluginDir); + expect(entries).toContain('my-custom-bundle'); + }); + + it('should pipe run output to console when verbose=true', async () => { + await bundleCommand({ ...defaultOpts, verbose: true }); + + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['yarn', 'install', '--immutable']), + expect.anything(), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('mock yarn output'), + ); + }); + + it('should write .bundle-output marker to the output directory', async () => { + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, '.bundle-output'], true); + }); + + it('should remove nested dirs that have a .bundle-output marker', async () => { + // Leave a marked directory from a previous bundle run in the source tree. + const prevBundleDir = joinPath(ctx.pluginDir, 'old-bundle'); + await fs.ensureDir(prevBundleDir); + await fs.writeFile(joinPath(prevBundleDir, '.bundle-output'), ''); + + // Simulate yarn pack pulling the stale dir into the packed output. + mockCreateDistWorkspace.mockImplementation(async (_pkgNames, opts) => { + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + opts.targetDir, + ); + const nestedDir = joinPath(opts.targetDir, ctx.relDir, 'old-bundle'); + await fs.ensureDir(nestedDir); + await fs.writeFile(joinPath(nestedDir, 'stale-file'), ''); + return { targets: [{ name: backendPkg.name, dir: ctx.pluginDir }] }; + }); + + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, 'old-bundle'], false); + }); + + it('should not create recursive nesting when run twice without --clean', async () => { + await bundleCommand(defaultOpts); + await bundleCommand(defaultOpts); + + const entries = await fs.readdir(ctx.targetDir); + expect(entries).not.toContain('bundle'); + }); + }); + + describe('error handling', () => { + it('should propagate error and show log when pruneBundleLockfile fails', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + mockRun + .mockReturnValueOnce({ waitForExit: () => Promise.resolve() }) + .mockImplementationOnce((_args: any, opts: any) => ({ + waitForExit: async () => { + (opts?.onStdout ?? opts?.onStderr)?.( + Buffer.from('yarn prune output line 1\nline 2\n'), + ); + throw new Error('prune failed'); + }, + })); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'prune failed', + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('--- last 20 lines ---'), + ); + }); + + it('should propagate error when installBundleDependencies fails', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + let callCount = 0; + mockRun.mockImplementation(() => ({ + waitForExit: () => + ++callCount === 2 + ? Promise.reject(new Error('install failed')) + : Promise.resolve(), + })); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'install failed', + ); + }); + }); + + describe('config schema', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should set configSchema in package.json when schemas are found', async () => { + mockLoadConfigSchema.mockResolvedValue({ + serialize: () => ({ + schemas: [ + { + packageName: backendPkg.name, + value: { + type: 'object', + properties: { foo: { type: 'string' } }, + }, + path: 'schemas/foo.json', + }, + ], + }), + }); + + await bundleCommand(defaultOpts); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.configSchema).toBe('dist/.config-schema.json'); + const schemaFile = await fs.readJson( + joinPath(ctx.targetDir, 'dist', '.config-schema.json'), + ); + expect(schemaFile.backstageConfigSchemaVersion).toBe(1); + expect(schemaFile.schemas).toHaveLength(1); + }); + + it('should not set configSchema when no schemas are found', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.configSchema).toBeUndefined(); + }); + }); + }); + + describe('frontend', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = setupPlugin(frontendPluginDir, frontendPkg); + setupPackToDirectoryMock(); + }); + + describe('without --pre-packed-dir', () => { + it('should produce frontend bundle when build=true', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + await expectPathExists([ctx.targetDir, '.gitignore'], true); + await expectPathExists([ctx.targetDir, '.yarnrc.yml'], false); + await expectPathExists([ctx.targetDir, 'yarn.lock'], false); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + await expectPathExists([ctx.targetDir, 'src'], false); + expect(mockBuildFrontend).toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + expect(mockCreateDistWorkspace).not.toHaveBeenCalled(); + }); + + it('should keep src/ when "files" explicitly includes it', async () => { + ctx = setupPlugin(frontendPluginDir, { + ...frontendPkg, + files: ['dist', 'src'], + }); + setupPackToDirectoryMock(); + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, 'src'], true); + }); + + it('should produce frontend bundle when build=false', async () => { + await bundleCommand({ ...defaultOpts, build: false }); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + await expectPathExists([ctx.targetDir, 'yarn.lock'], false); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + expect(mockBuildFrontend).not.toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + }); + + it('should produce frontend bundle for frontend-plugin-module role', async () => { + ctx = setupPlugin(frontendPluginDir, { + ...frontendPkg, + backstage: { role: 'frontend-plugin-module' }, + }); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + expect(mockBuildFrontend).toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + expect(mockCreateDistWorkspace).not.toHaveBeenCalled(); + }); + }); + + describe('package.json post-processing', () => { + it('should apply frontend-specific and common post-processing', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.types).toBe('./dist/@mf-types/index.d.ts'); + expect(pkg.exports).toBeUndefined(); + expect(pkg.module).toBeUndefined(); + expect(pkg.typesVersions).toBeUndefined(); + expect(pkg.dependencies).toEqual({ + '@backstage/catalog-model': '^1.7.6', + }); + expect(pkg.keywords).toEqual(['backstage']); + expect(pkg.license).toBe('Apache-2.0'); + expect(pkg.backstage).toEqual( + expect.objectContaining({ role: 'frontend-plugin' }), + ); + expect(pkg.bundleDependencies).toBeUndefined(); + expect(pkg.scripts).toEqual({}); + expect(pkg.devDependencies).toEqual({}); + }); + + it('should delete types when @mf-types/index.d.ts is absent', async () => { + mockPackToDirectory.mockImplementation( + async (opts: { targetDir: string; packageName: string }) => { + await fs.copy( + joinPath(fixturesDir, 'packed', 'frontend'), + opts.targetDir, + ); + await fs.remove( + joinPath(opts.targetDir, 'dist', '@mf-types', 'index.d.ts'), + ); + const pkgPath = joinPath(opts.targetDir, 'package.json'); + const pkg = await fs.readJson(pkgPath); + pkg.name = opts.packageName; + await fs.writeJson(pkgPath, pkg); + }, + ); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.types).toBeUndefined(); + }); + }); + + describe('with --pre-packed-dir', () => { + beforeEach(async () => { + mockListTargetPackages.mockResolvedValue([ + { packageJson: { name: frontendPkg.name }, dir: ctx.pluginDir }, + ]); + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'frontend'), + prePackedPath, + ); + mockBuildFrontend.mockImplementation(async () => { + const distDir = joinPath(ctx.pluginDir, 'dist'); + await fs.ensureDir(distDir); + await fs.writeFile( + joinPath(distDir, 'remoteEntry.js'), + '// MF remote entry', + ); + }); + }); + + it('should copy from pre-packed and prune lockfile but not install', async () => { + await bundleCommand({ + ...defaultOpts, + prePackedDir: joinPath(mockDir.path, 'pre-packed'), + }); + + await expectPathExists([ctx.targetDir, 'package.json'], true); + await expectPathExists([ctx.targetDir, 'yarn.lock'], true); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + await expectPathExists([ctx.targetDir, 'dist', 'remoteEntry.js'], true); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['--mode', 'update-lockfile']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).not.toHaveBeenCalledWith( + expect.arrayContaining(['--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + }); + }); + + describe('error handling', () => { + it('should propagate error when packToDirectory fails', async () => { + mockPackToDirectory.mockRejectedValue(new Error('pack failed')); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow('pack failed'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + }); + + it('should not show last 20 lines when verbose=true on failure', async () => { + mockPackToDirectory.mockRejectedValue(new Error('pack failed')); + + await expect( + bundleCommand({ ...defaultOpts, verbose: true }), + ).rejects.toThrow('pack failed'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('--- last 20 lines ---'), + ); + }); + }); + }); +}); diff --git a/packages/cli-module-build/src/commands/package/bundle/command.ts b/packages/cli-module-build/src/commands/package/bundle/command.ts new file mode 100644 index 0000000000..ee88322733 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/command.ts @@ -0,0 +1,979 @@ +/* + * 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 { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; +import { run, runOutput } from '@backstage/cli-common'; +import chalk from 'chalk'; +import { cli } from 'cleye'; +import fs from 'fs-extra'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { + join as joinPath, + resolve as resolvePath, + relative as relativePath, +} from 'node:path'; + +import { loadConfigSchema } from '@backstage/config-loader'; +import { targetPaths } from '@backstage/cli-common'; +import { buildFrontend } from '../../../lib/buildFrontend'; +import { + createDistWorkspace, + packToDirectory, + resolveLocalDependencies, +} from '../../../lib/packager'; +import type { CliCommandContext } from '@backstage/cli-node'; + +interface BundleOptions { + build: boolean; + install: boolean; + clean: boolean; + verbose: boolean; + outputDestination?: string; + outputName?: string; + prePackedDir?: string; +} + +/** + * Bundle a plugin for dynamic loading. + * + * This creates a self-contained plugin bundle that can be deployed independently + * and loaded dynamically by a Backstage application. Supports both backend and + * frontend plugins. + * + * For backend plugins, `createDistWorkspace` handles building (CJS) and packing + * all local dependencies. The output is restructured so that the main plugin + * sits at the bundle root and its local dependencies live under `embedded/`. + * A lockfile is seeded, pruned, and used to install a private `node_modules`. + * + * For frontend plugins, a module federation remote build produces the final + * assets. Only the main plugin is packed into the bundle root (no `embedded/`, + * no lockfile, no `node_modules`). + * + * When `--pre-packed-dir` is provided, local dependencies are copied from a + * pre-built dist workspace instead of calling `createDistWorkspace`: + * - For backend plugins this is a performance optimization when many plugins from the same monorepo are being bundled. + * - For frontend plugins it additionally enables lockfile generation (seed + prune) for dependency tracking purposes such as SBOM generation. + * - The pre-built dist workspace is produced by + * `backstage-cli build-workspace [packages...] --alwaysPack` + * and `` is then passed as `--pre-packed-dir`. + * - The `--alwaysPack` flag is required so that `workspace:^` and `backstage:^` + * dependency specs are resolved to concrete versions in the packed output. + */ +export async function bundleCommand(opts: BundleOptions): Promise { + const pkgJsonPath = targetPaths.resolve('package.json'); + const pkg = (await fs.readJson(pkgJsonPath)) as BackstagePackageJson; + + const outputDestination = opts.outputDestination + ? resolvePath(opts.outputDestination) + : targetPaths.dir; + const mangledName = pkg.name.replace(/^@/, '').replace(/\//, '-'); + let bundleName = 'bundle'; + if (opts.outputName) { + bundleName = opts.outputName; + } else if (opts.outputDestination) { + bundleName = mangledName; + } + const target = joinPath(outputDestination, bundleName); + + const role = pkg.backstage?.role; + if (!role) { + throw new Error( + `Package ${chalk.cyan( + pkg.name, + )} does not have a backstage.role defined in package.json`, + ); + } + + const validRoles = [ + 'backend-plugin', + 'backend-plugin-module', + 'frontend-plugin', + 'frontend-plugin-module', + ]; + if (!validRoles.includes(role)) { + throw new Error( + `Package ${chalk.cyan(pkg.name)} has role ${chalk.cyan( + role, + )}, but bundle command ` + + `only supports: ${validRoles.map(r => chalk.cyan(r)).join(', ')}`, + ); + } + + const pluginType: 'frontend' | 'backend' = + role === 'frontend-plugin' || role === 'frontend-plugin-module' + ? 'frontend' + : 'backend'; + + if (pkg.bundled) { + throw new Error( + `Package ${chalk.cyan(pkg.name)} has ${chalk.cyan( + 'bundled: true', + )} which is not ` + `compatible with dynamic plugin bundling.`, + ); + } + + console.log( + chalk.blue(`Bundling ${chalk.cyan(pkg.name)} for dynamic loading...`), + ); + console.log(`${chalk.dim('Output:')} ${chalk.cyan(target)}`); + + if (opts.clean) { + console.log(chalk.blue(`Cleaning ${chalk.cyan(target)}`)); + await fs.remove(target); + } + + await fs.mkdirs(target); + + await fs.writeFile(joinPath(target, '.gitignore'), '*\n'); + await fs.writeFile(joinPath(target, '.bundle-output'), ''); + + const rootPkg = await fs.readJson( + resolvePath(targetPaths.rootDir, 'package.json'), + ); + + // Backend plugins always need embedded packages, lockfile, and node_modules. + // Frontend plugins need them only when --pre-packed-dir is provided. + const needsDependencies = pluginType === 'backend' || !!opts.prePackedDir; + + // Establish the bundle directory as its own Yarn project root so that + // the seeded yarn.lock is the one Yarn reads/writes, even when the + // output directory is inside another monorepo. + // Only needed when lockfile/install operations will run. + if (needsDependencies) { + const yarnrcLines = ['nodeLinker: node-modules']; + try { + // Include yarnPath so the same Yarn version that created the lockfile + // is used for pruning/installing -- lockfile formats differ across + // major Yarn versions (e.g. ~builtin vs optional!builtin patches). + const resolved = await runOutput(['yarn', 'config', 'get', 'yarnPath'], { + cwd: targetPaths.rootDir, + }); + const yarnPathSentinels = new Set(['undefined', 'null']); + if (resolved && !yarnPathSentinels.has(resolved)) { + yarnrcLines.push(`yarnPath: ${resolved}`); + } + } catch { + // yarnPath not configured — check if corepack manages the version instead + if (!rootPkg.packageManager) { + console.warn( + chalk.yellow( + 'No yarnPath configured and no packageManager field found. ' + + 'The Yarn version in PATH will be used for lockfile operations.', + ), + ); + } + } + + await fs.writeFile( + joinPath(target, '.yarnrc.yml'), + `${yarnrcLines.join('\n')}\n`, + ); + } + + // ── Step 0 (frontend only): Module federation build ───────────────── + if (pluginType === 'frontend' && opts.build) { + console.log(chalk.blue('Building module federation remote...')); + await buildFrontend({ + targetDir: targetPaths.dir, + configPaths: [], + writeStats: false, + isModuleFederationRemote: true, + }); + } + + const embeddedResolutions: Record = {}; + + // Detect previous bundle output directories inside the source package so + // they can be stripped from the yarn-pack result later. npm-packlist's + // basename matching on the `files` field picks up identically-named entries + // from prior bundle outputs, creating nested copies. + const bundleOutputDirs: string[] = []; + try { + const entries = await fs.readdir(targetPaths.dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if ( + await fs.pathExists( + joinPath(targetPaths.dir, entry.name, '.bundle-output'), + ) + ) { + bundleOutputDirs.push(entry.name); + } + } + } + } catch { + /* directory may not exist yet on first run */ + } + + if (needsDependencies) { + // ── Step 1: Populate embedded packages ────────────────────────────── + const embeddedDir = joinPath(target, 'embedded'); + let targets: { name: string; dir: string }[]; + + if (opts.prePackedDir) { + // ── Strategy A: Copy from pre-built dist workspace ─────────────── + // Reuses output from `backstage-cli build-workspace --alwaysPack`. + // Works for both backend (performance optimization) and frontend + // (enables lockfile generation for SBOM). + const prePackedDir = resolvePath(opts.prePackedDir); + console.log( + chalk.blue( + `Using pre-packed workspace at ${chalk.cyan(prePackedDir)}...`, + ), + ); + + const packages = await PackageGraph.listTargetPackages(); + targets = await resolveLocalDependencies([pkg.name], packages); + await fs.ensureDir(embeddedDir); + + for (const dep of targets) { + const relDir = relativePath(targetPaths.rootDir, dep.dir); + const srcDir = resolvePath(prePackedDir, relDir); + const destDir = resolvePath(embeddedDir, relDir); + if (await fs.pathExists(srcDir)) { + await fs.copy(srcDir, destDir); + } else { + console.warn( + chalk.yellow( + ` Package ${chalk.cyan(dep.name)} not found in pre-packed ` + + `dir (expected at ${chalk.cyan(relDir)})`, + ), + ); + } + } + } else { + // ── Strategy B: createDistWorkspace ────────────────────────────── + // Pack all local dependencies into a temp directory first, then + // move the result into target/embedded/. We must NOT pack directly + // into the target tree because yarn pack's basename-matching on the + // `files` field would pick up identically-named files from + // already-extracted embedded packages. + const tempWorkspaceDir = await fs.mkdtemp( + joinPath(tmpdir(), 'bundle-workspace-'), + ); + console.log(chalk.blue('Packing local dependencies...')); + + const distLog = createStepLogger( + joinPath(target, 'dist-workspace.log'), + opts.verbose, + ); + + const packingPattern = /^(?:Moving|Repacking) (.+) into dist workspace$/; + const distLogger = { + log(msg: string) { + const match = msg.match(packingPattern); + if (match) { + console.log(` ${chalk.dim('Packing')} ${chalk.cyan(match[1])}`); + } + distLog.logger.log(msg); + }, + warn(msg: string) { + console.warn(` ${chalk.yellow(msg)}`); + distLog.logger.warn(msg); + }, + }; + + try { + ({ targets } = await createDistWorkspace([pkg.name], { + targetDir: tempWorkspaceDir, + files: [], + alwaysPack: true, + buildDependencies: opts.build, + buildExcludes: opts.build ? [] : undefined, + logger: distLogger, + })); + await fs.remove(embeddedDir); + await fs.move(tempWorkspaceDir, embeddedDir); + } catch (err) { + await distLog.close(); + await showLogOnError(distLog.path, opts.verbose); + throw err; + } finally { + if (await fs.pathExists(tempWorkspaceDir)) { + await fs.remove(tempWorkspaceDir); + } + } + await distLog.close(); + await fs.remove(distLog.path); + } + + // ── Step 2: Assemble embedded packages ────────────────────────────── + const mainPluginRelDir = relativePath(targetPaths.rootDir, targetPaths.dir); + const mainPluginEmbeddedDir = resolvePath(embeddedDir, mainPluginRelDir); + + console.log( + chalk.blue( + `Moving main plugin ${chalk.cyan(pkg.name)} to bundle root...`, + ), + ); + + if (!(await fs.pathExists(mainPluginEmbeddedDir))) { + throw new Error( + `Main plugin ${chalk.cyan(pkg.name)} was not found in the ` + + `embedded workspace at ${chalk.cyan(mainPluginRelDir)}. ` + + `Ensure the pre-packed workspace includes this plugin.`, + ); + } + + const mainPluginEntries = await fs.readdir(mainPluginEmbeddedDir); + for (const entry of mainPluginEntries) { + await fs.move( + joinPath(mainPluginEmbeddedDir, entry), + joinPath(target, entry), + { overwrite: true }, + ); + } + + await fs.remove(mainPluginEmbeddedDir); + + // For frontend plugins, the pre-packed dist/ contains standard CJS + // output, not Module Federation artifacts. Overlay the MF build + // output from the source directory (produced by Step 0). + if (pluginType === 'frontend') { + const sourceDist = resolvePath(targetPaths.dir, 'dist'); + if (await fs.pathExists(sourceDist)) { + await fs.copy(sourceDist, joinPath(target, 'dist'), { + overwrite: true, + }); + } + } + + const localDeps = targets.filter(t => t.name !== pkg.name); + + for (const dep of localDeps) { + const depRelDir = relativePath(targetPaths.rootDir, dep.dir); + const depEmbeddedDir = resolvePath(embeddedDir, depRelDir); + + if (!(await fs.pathExists(depEmbeddedDir))) { + continue; + } + + embeddedResolutions[dep.name] = `file:./embedded/${depRelDir}`; + } + + if (Object.keys(embeddedResolutions).length === 0) { + await fs.remove(embeddedDir); + } + } else { + // ── Step 1b: Pack main plugin only ────────────────────────────────── + // Frontend plugins without --pre-packed-dir don't need transitive + // local deps -- just pack the main plugin directly into the bundle root. + console.log(chalk.blue(`Packing main plugin ${chalk.cyan(pkg.name)}...`)); + + const distLog = createStepLogger( + joinPath(target, 'dist-workspace.log'), + opts.verbose, + ); + + try { + await packToDirectory({ + packageDir: targetPaths.dir, + packageName: pkg.name, + targetDir: target, + logger: distLog.logger, + }); + } catch (err) { + await distLog.close(); + await showLogOnError(distLog.path, opts.verbose); + throw err; + } + await distLog.close(); + await fs.remove(distLog.path); + } + + // Remove any previous bundle output directories that were erroneously + // included by yarn pack (npm-packlist's basename matching on the `files` + // field picks up identically-named entries from prior bundle outputs). + for (const dir of bundleOutputDirs) { + const nestedPath = joinPath(target, dir); + if (await fs.pathExists(nestedPath)) { + await fs.remove(nestedPath); + } + } + + // Remove src/ directory included by yarn pack because prepack's + // rewriteEntryPoints cannot rewrite main/types away from src/ paths + // when the standard dist output files are absent (MF builds). + // Skip if the package explicitly ships src/ via the "files" field. + if (pluginType === 'frontend') { + const srcExplicitlyIncluded = (pkg.files ?? []).some( + f => f === 'src' || f.startsWith('src/'), + ); + if (!srcExplicitlyIncluded) { + const srcPath = joinPath(target, 'src'); + if (await fs.pathExists(srcPath)) { + await fs.remove(srcPath); + } + } + } + + // ── Step 3: Config schema ──────────────────────────────────────────── + console.log(chalk.blue('Filtering config schema...')); + + const schemaPath = resolvePath(target, 'dist', '.config-schema.json'); + let schemas: Array<{ packageName: string }> = []; + + if (pluginType === 'frontend' && (await fs.pathExists(schemaPath))) { + const existing = await fs.readJson(schemaPath); + schemas = existing.schemas ?? []; + } else { + const configSchema = await loadConfigSchema({ + dependencies: [], + packagePaths: ['package.json'], + }); + const serialized = configSchema.serialize() as { + schemas: typeof schemas; + }; + schemas = serialized.schemas ?? []; + } + + let schemaWritten = false; + if (schemas.length > 0) { + const filtered = filterBundleConfigSchemas(schemas, targetPaths.dir); + if (filtered.length > 0) { + await fs.ensureDir(resolvePath(target, 'dist')); + await fs.writeJson( + schemaPath, + { backstageConfigSchemaVersion: 1, schemas: filtered }, + { spaces: 2 }, + ); + schemaWritten = true; + } else { + console.log( + chalk.dim(' No config schemas found for this plugin bundle'), + ); + } + } else { + console.log(chalk.dim(' No config schemas found for this plugin bundle')); + } + + // ── Step 4: Post-process package.json ──────────────────────────────── + console.log( + chalk.blue( + `Customizing ${chalk.cyan('package.json')} for dynamic loading...`, + ), + ); + + const targetPkgPath = resolvePath(target, 'package.json'); + const targetPkg = await fs.readJson(targetPkgPath); + + postProcessBundlePackageJson( + targetPkg, + target, + pluginType, + needsDependencies ? rootPkg?.resolutions : undefined, + embeddedResolutions, + needsDependencies, + ); + + if (schemaWritten) { + targetPkg.configSchema = 'dist/.config-schema.json'; + } + + await fs.writeJson(targetPkgPath, targetPkg, { spaces: 2 }); + + // ── Step 5: Seed lockfile, prune, & install ────────────────────────── + // Runs for backend plugins (always) and frontend plugins with + // --pre-packed-dir (for SBOM lockfile generation). + if (needsDependencies) { + await seedBundleLockfile(target, targetPaths.dir, targetPaths.rootDir); + + const sourceCacheFolder = await runOutput( + ['yarn', 'config', 'get', 'cacheFolder'], + { cwd: targetPaths.rootDir }, + ); + await pruneBundleLockfile(target, opts.verbose, sourceCacheFolder); + + if (pluginType === 'backend') { + if (opts.install) { + await installBundleDependencies(target, opts.verbose); + + // Clean up .yarn directory created during install + const yarnDir = joinPath(target, '.yarn'); + if (await fs.pathExists(yarnDir)) { + await fs.remove(yarnDir); + } + + console.log(chalk.blue('Validating plugin entry points...')); + + // Temporarily patch module resolution so the plugin can resolve + // itself by name (needed by resolvePackagePath at module load + // time, e.g. for database migration paths). At runtime this is + // handled by CommonJSModuleLoader in backend-dynamic-feature-service. + const NodeModule = + require('node:module') as typeof import('node:module') & { + _resolveFilename: Function; + }; + const origResolveFilename = NodeModule._resolveFilename; + NodeModule._resolveFilename = (request: string, ...args: any[]) => { + if (request === `${targetPkg.name}/package.json`) { + return resolvePath(target, 'package.json'); + } + return origResolveFilename(request, ...args); + }; + + try { + const pluginRequire = createRequire(`${target}/package.json`); + const mainModule = pluginRequire(target); + const alphaPath = resolvePath(target, 'alpha'); + const alphaModule = (await fs.pathExists(alphaPath)) + ? pluginRequire(alphaPath) + : undefined; + + const isBackendFeature = (v: unknown) => + !!v && + (typeof v === 'object' || typeof v === 'function') && + (v as { $$type?: string }).$$type === '@backstage/BackendFeature'; + + const hasValidExport = [mainModule, alphaModule] + .filter(Boolean) + .some(m => isBackendFeature(m?.default)); + + if (!hasValidExport) { + throw new Error( + `Backend plugin is not valid for dynamic loading: ` + + `it must export a ${chalk.cyan( + 'BackendFeature', + )} as default export`, + ); + } + } finally { + NodeModule._resolveFilename = origResolveFilename; + } + } else { + console.log( + chalk.yellow( + 'Skipping dependency installation and validation. ' + + 'Run without --no-install to validate the bundle.', + ), + ); + } + } + } + + console.log(chalk.green(`Bundle created at ${chalk.cyan(target)}`)); +} + +/** + * Mutates `targetPkg` in place to prepare it for dynamic plugin loading: + * clears scripts/devDependencies, applies plugin-type-specific adjustments + * (MF entry points for frontend, bundleDependencies for backend), and merges + * root + embedded resolutions when a lockfile is involved. + */ +export function postProcessBundlePackageJson( + targetPkg: Record, + targetDir: string, + pluginType: 'frontend' | 'backend', + rootResolutions: Record | undefined, + embeddedResolutions: Record, + needsDependencies: boolean, +): void { + targetPkg.scripts = {}; + targetPkg.devDependencies = {}; + + if (pluginType === 'frontend') { + targetPkg.main = './dist/remoteEntry.js'; + + const mfTypesIndex = resolvePath( + targetDir, + 'dist', + '@mf-types', + 'index.d.ts', + ); + if (fs.pathExistsSync(mfTypesIndex)) { + targetPkg.types = './dist/@mf-types/index.d.ts'; + } else { + delete targetPkg.types; + } + + delete targetPkg.exports; + delete targetPkg.module; + delete targetPkg.typesVersions; + } else if (pluginType === 'backend') { + targetPkg.bundleDependencies = true; + } + + if (needsDependencies) { + const patchVersionPattern = /^patch:.+@npm%3A([^#]+)#/; + const stripped: Record = {}; + if (rootResolutions) { + for (const [key, value] of Object.entries(rootResolutions)) { + if (typeof value !== 'string') { + continue; + } + const patchMatch = value.match(patchVersionPattern); + stripped[key] = patchMatch ? patchMatch[1] : value; + } + } + + targetPkg.resolutions = { + ...stripped, + ...(targetPkg.resolutions as Record | undefined), + ...embeddedResolutions, + }; + } +} + +/** + * Seeds the bundle's yarn.lock from the source plugin or monorepo lockfile. + * Looks first for a local yarn.lock in the plugin directory, then falls back + * to the monorepo root. + */ +async function seedBundleLockfile( + targetDir: string, + pluginDir: string, + monorepoRoot: string, +): Promise { + let sourceYarnLock: string | undefined; + if (await fs.pathExists(joinPath(pluginDir, 'yarn.lock'))) { + sourceYarnLock = joinPath(pluginDir, 'yarn.lock'); + } else if (await fs.pathExists(joinPath(monorepoRoot, 'yarn.lock'))) { + sourceYarnLock = joinPath(monorepoRoot, 'yarn.lock'); + } + + if (!sourceYarnLock) { + throw new Error( + `Could not find a ${chalk.cyan( + 'yarn.lock', + )} file in either the plugin directory or the monorepo root (${chalk.cyan( + monorepoRoot, + )})`, + ); + } + + const isMonorepoLock = sourceYarnLock === joinPath(monorepoRoot, 'yarn.lock'); + console.log( + chalk.blue( + `Seeding bundle ${chalk.cyan('yarn.lock')} from source plugin${ + isMonorepoLock ? ' monorepo' : '' + } lockfile...`, + ), + ); + + await fs.copyFile(sourceYarnLock, resolvePath(targetDir, 'yarn.lock')); +} + +/** + * Prunes the bundle's yarn.lock to remove entries not required by the + * bundle's package.json. Runs offline to avoid network access. + */ +async function pruneBundleLockfile( + targetDir: string, + verbose: boolean, + sourceCacheFolder: string, +): Promise { + console.log( + chalk.blue( + `Pruning bundle ${chalk.cyan( + 'yarn.lock', + )} to remove unused dependencies...`, + ), + ); + + const pruneLog = createStepLogger( + joinPath(targetDir, 'lockfile-prune.log'), + verbose, + '[lockfile-prune] ', + ); + try { + await run( + ['yarn', 'install', '--no-immutable', '--mode', 'update-lockfile'], + { + cwd: targetDir, + env: { + YARN_ENABLE_GLOBAL_CACHE: 'false', + YARN_ENABLE_NETWORK: '0', + YARN_ENABLE_MIRROR: 'false', + YARN_CACHE_FOLDER: sourceCacheFolder, + }, + onStdout: pruneLog.logRunOutput('out'), + onStderr: pruneLog.logRunOutput('err'), + }, + ).waitForExit(); + } catch (err) { + await pruneLog.close(); + await showLogOnError(pruneLog.path, verbose); + throw err; + } + await pruneLog.close(); + await fs.remove(pruneLog.path); +} + +/** + * Installs the bundle's dependencies using an immutable lockfile. + * This creates the node_modules directory needed for backend plugin runtime. + */ +async function installBundleDependencies( + targetDir: string, + verbose: boolean, +): Promise { + console.log(chalk.blue('Installing private dependencies...')); + + const installLog = createStepLogger( + joinPath(targetDir, 'yarn-install.log'), + verbose, + '[yarn-install] ', + ); + try { + await run(['yarn', 'install', '--immutable'], { + cwd: targetDir, + onStdout: installLog.logRunOutput('out'), + onStderr: installLog.logRunOutput('err'), + }).waitForExit(); + } catch (err) { + await installLog.close(); + await showLogOnError(installLog.path, verbose); + throw err; + } + await installLog.close(); + await fs.remove(installLog.path); +} + +/** + * Filters config schemas to keep only those belonging to the plugin's own + * family: the plugin itself, same-pluginId libraries, third-party packages, + * and recursively any directly-depended plugin/module (wrapper scenario). + */ +export function filterBundleConfigSchemas( + schemas: { packageName: string }[], + pluginDir: string, +): { packageName: string }[] { + const PLUGIN_OR_MODULE_ROLES = new Set([ + 'backend-plugin', + 'backend-plugin-module', + 'frontend-plugin', + 'frontend-plugin-module', + ]); + const LIBRARY_ROLES = new Set([ + 'node-library', + 'common-library', + 'web-library', + ]); + + const allowed = new Set(); + const visited = new Set(); + const localRequire = createRequire(resolvePath(pluginDir, 'package.json')); + + function walk(pkg: BackstagePackageJson) { + if (visited.has(pkg.name)) { + return; + } + visited.add(pkg.name); + allowed.add(pkg.name); + + const pluginId = pkg.backstage?.pluginId; + + for (const depName of Object.keys(pkg.dependencies ?? {})) { + let depPkgPath: string; + try { + depPkgPath = localRequire.resolve(`${depName}/package.json`); + } catch { + continue; + } + + let depPkg: BackstagePackageJson; + try { + depPkg = fs.readJsonSync(depPkgPath); + } catch { + continue; + } + + const depRole = depPkg.backstage?.role; + + if (!depPkg.backstage) { + allowed.add(depName); + continue; + } + + if (depRole && PLUGIN_OR_MODULE_ROLES.has(depRole)) { + walk(depPkg); + continue; + } + + if ( + depRole && + LIBRARY_ROLES.has(depRole) && + pluginId && + depPkg.backstage?.pluginId === pluginId + ) { + allowed.add(depName); + continue; + } + } + } + + let rootPkg: BackstagePackageJson; + try { + rootPkg = fs.readJsonSync(resolvePath(pluginDir, 'package.json')); + } catch { + return []; + } + walk(rootPkg); + + return schemas.filter(s => allowed.has(s.packageName)); +} + +const ansiPattern = + // eslint-disable-next-line no-control-regex + /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g; +function stripAnsi(str: string): string { + return str.replace(ansiPattern, ''); +} + +function createStepLogger( + logFilePath: string, + verbose: boolean, + prefix?: string, +) { + const logStream = fs.createWriteStream(logFilePath); + + const writeLine = (line: string, stream: 'out' | 'err') => { + const prefixed = prefix ? `${prefix}${line}` : line; + logStream.write( + `${stream === 'err' ? '[WARN] ' : ''}${stripAnsi(prefixed)}\n`, + ); + if (verbose) { + const writer = stream === 'err' ? console.warn : console.log; + writer(chalk.dim(prefixed)); + } + }; + + const logger = { + log(msg: string) { + writeLine(msg, 'out'); + }, + warn(msg: string) { + writeLine(msg, 'err'); + }, + }; + + const logRunOutput = (stream: 'out' | 'err') => (data: Buffer) => { + if (prefix) { + for (const line of data.toString('utf8').split(/\r?\n/)) { + if (line) writeLine(line, stream); + } + } else { + logStream.write( + `${stream === 'err' ? '[WARN] ' : ''}${stripAnsi( + data.toString('utf8'), + )}`, + ); + if (verbose) { + const writer = stream === 'err' ? console.warn : console.log; + writer(chalk.dim(data.toString('utf8'))); + } + } + }; + + const close = () => new Promise(r => logStream.end(r)); + + return { logger, logRunOutput, close, path: logFilePath }; +} + +async function showLogOnError( + logFilePath: string, + verbose: boolean, +): Promise { + console.error( + chalk.red(`\nFull log available at: ${chalk.cyan(logFilePath)}`), + ); + if (!verbose) { + try { + const content = await fs.readFile(logFilePath, 'utf8'); + const tail = content.split('\n').slice(-20).join('\n'); + if (tail) { + console.error(chalk.dim('\n--- last 20 lines ---')); + console.error(tail); + } + } catch { + /* log file may not exist yet */ + } + } +} + +export default async ({ args, info }: CliCommandContext) => { + const { + flags: { + outputDestination, + outputName, + clean, + noBuild, + noInstall, + verbose, + prePackedDir, + }, + } = cli( + { + help: info, + flags: { + outputDestination: { + type: String, + description: + 'Directory in which the bundle subdirectory is created. ' + + 'Defaults to the current package directory.', + }, + outputName: { + type: String, + description: + 'Name of the bundle subdirectory. ' + + 'Defaults to "bundle" when output stays in the package directory, ' + + 'or to the mangled package name (e.g. myorg-plugin-foo) when ' + + '--output-destination is specified.', + }, + clean: { + type: Boolean, + description: 'Clean the output directory before bundling', + }, + noBuild: { + type: Boolean, + description: + 'Skip building packages (assumes they are already built)', + }, + noInstall: { + type: Boolean, + description: + 'Skip dependency installation and entrypoint validation.', + }, + verbose: { + type: Boolean, + description: + 'Stream detailed output from internal steps (build, pack, install) to the console. ' + + 'Without this flag, output is captured to per-step log files and only shown on error.', + }, + prePackedDir: { + type: String, + description: + 'Path to a pre-built dist workspace (from build-workspace --alwaysPack). ' + + 'Skips local dependency packing and uses pre-packed packages directly. ' + + 'For frontend plugins, this also enables yarn.lock generation for SBOM.', + }, + }, + }, + undefined, + args, + ); + + return bundleCommand({ + build: !noBuild, + install: !noInstall, + clean: Boolean(clean), + verbose: Boolean(verbose), + outputDestination: outputDestination ?? undefined, + outputName: outputName ?? undefined, + prePackedDir: prePackedDir ?? undefined, + }); +}; diff --git a/packages/cli-module-build/src/commands/package/bundle/index.ts b/packages/cli-module-build/src/commands/package/bundle/index.ts new file mode 100644 index 0000000000..d2d2779ce3 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { default } from './command'; diff --git a/packages/cli-module-build/src/index.ts b/packages/cli-module-build/src/index.ts index 887a4b562c..cf892db53c 100644 --- a/packages/cli-module-build/src/index.ts +++ b/packages/cli-module-build/src/index.ts @@ -33,6 +33,16 @@ export default createCliModule({ execute: { loader: () => import('./commands/repo/build') }, }); + reg.addCommand({ + path: ['package', 'bundle'], + description: + 'Bundle a plugin for dynamic loading. Creates a self-contained plugin ' + + 'package that can be deployed and loaded dynamically by a Backstage application. ' + + 'Supports both backend and frontend plugins. Experimental.', + experimental: true, + execute: { loader: () => import('./commands/package/bundle') }, + }); + reg.addCommand({ path: ['package', 'start'], description: 'Start a package for local development', diff --git a/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts b/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts index 535d54f9dc..72a06af69f 100644 --- a/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts @@ -38,6 +38,7 @@ import { } from '../builder'; import { productionPack } from './productionPack'; import { + BackstagePackage, PackageRoles, PackageGraph, PackageGraphNode, @@ -109,12 +110,21 @@ type Options = { * If set to true, the generated code will be minified. */ minify?: boolean; + + /** + * Optional logger to route console output through. When not provided, + * output goes to `console.log` / `console.warn` as before. + */ + logger?: { + log(msg: string): void; + warn(msg: string): void; + }; }; -function prefixLogFunc(prefix: string, out: 'stdout' | 'stderr') { +function prefixLogFunc(prefix: string, logFn: (msg: string) => void) { return (data: Buffer) => { for (const line of data.toString('utf8').split(/\r?\n/)) { - process[out].write(`${prefix} ${line}\n`); + if (line) logFn(`${prefix}${line}`); } }; } @@ -131,21 +141,14 @@ export async function createDistWorkspace( packageNames: string[], options: Options = {}, ) { + const logger = options.logger ?? { log: console.log, warn: console.warn }; + const targetDir = options.targetDir ?? (await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace'))); const packages = await PackageGraph.listTargetPackages(); - const packageGraph = PackageGraph.fromPackages(packages); - const targetNames = packageGraph.collectPackageNames(packageNames, node => { - // Don't include dependencies of packages that are marked as bundled - if (node.packageJson.bundled) { - return undefined; - } - - return node.publishedLocalDependencies.keys(); - }); - const targets = Array.from(targetNames).map(name => packageGraph.get(name)!); + const targets = await resolveLocalDependencies(packageNames, packages); if (options.buildDependencies) { const exclude = options.buildExcludes ?? []; @@ -168,7 +171,7 @@ export async function createDistWorkspace( } const role = pkg.packageJson.backstage?.role; if (!role) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it has no role`, ); customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name }); @@ -182,7 +185,7 @@ export async function createDistWorkspace( } if (!buildScript.startsWith('backstage-cli package build')) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it has a custom build script, '${buildScript}'`, ); customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name }); @@ -190,7 +193,7 @@ export async function createDistWorkspace( } if (PackageRoles.getRoleInfo(role).output.includes('bundle')) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it is a bundled package`, ); const args = buildScript.includes('--config') @@ -227,8 +230,8 @@ export async function createDistWorkspace( worker: async ({ name, dir, args }) => { await run(['yarn', 'run', 'build', ...(args || [])], { cwd: dir, - onStdout: prefixLogFunc(`${name}: `, 'stdout'), - onStderr: prefixLogFunc(`${name}: `, 'stderr'), + onStdout: prefixLogFunc(`${name}: `, logger.log), + onStderr: prefixLogFunc(`${name}: `, logger.warn), }).waitForExit(); }, }); @@ -240,6 +243,7 @@ export async function createDistWorkspace( targets, Boolean(options.alwaysPack), Boolean(options.enableFeatureDetection), + logger, ); const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; @@ -270,7 +274,7 @@ export async function createDistWorkspace( ); } - return targetDir; + return { targetDir, targets }; } const FAST_PACK_SCRIPTS = [ @@ -279,11 +283,40 @@ const FAST_PACK_SCRIPTS = [ 'backstage-cli package prepack', ]; +/** + * Runs `yarn pack` on a single package and extracts the resulting tarball + * into `targetDir`. This resolves `workspace:^` and `backstage:^` dependency + * specs to concrete versions via yarn's `beforeWorkspacePacking` hook. + */ +export async function packToDirectory(options: { + packageDir: string; + packageName: string; + targetDir: string; + archivePath?: string; + logger: { log(msg: string): void; warn(msg: string): void }; +}): Promise { + const { packageDir, packageName, targetDir, logger } = options; + const archivePath = + options.archivePath ?? resolvePath(targetDir, 'temp-archive.tgz'); + const prefix = `${packageName} [pack]: `; + + await fs.ensureDir(targetDir); + await run(['yarn', 'pack', '--filename', archivePath], { + cwd: packageDir, + onStdout: prefixLogFunc(prefix, logger.log), + onStderr: prefixLogFunc(prefix, logger.warn), + }).waitForExit(); + + await tar.extract({ file: archivePath, cwd: targetDir, strip: 1 }); + await fs.remove(archivePath); +} + async function moveToDistWorkspace( workspaceDir: string, localPackages: PackageGraphNode[], alwaysPack: boolean, enableFeatureDetection: boolean, + logger: { log(msg: string): void; warn(msg: string): void }, ): Promise { const [fastPackPackages, slowPackPackages] = partition( localPackages, @@ -300,7 +333,7 @@ async function moveToDistWorkspace( // New an improved flow where we avoid calling `yarn pack` await Promise.all( fastPackPackages.map(async target => { - console.log(`Moving ${target.name} into dist workspace`); + logger.log(`Moving ${target.name} into dist workspace`); const outputDir = relativePath(targetPaths.rootDir, target.dir); const absoluteOutputPath = resolvePath(workspaceDir, outputDir); @@ -315,23 +348,18 @@ async function moveToDistWorkspace( // Old flow is below, which calls `yarn pack` and extracts the tarball async function pack(target: PackageGraphNode, archive: string) { - console.log(`Repacking ${target.name} into dist workspace`); - const archivePath = resolvePath(workspaceDir, archive); - - await run(['yarn', 'pack', '--filename', archivePath], { - cwd: target.dir, - }).waitForExit(); - - const outputDir = relativePath(targetPaths.rootDir, target.dir); - const absoluteOutputPath = resolvePath(workspaceDir, outputDir); - await fs.ensureDir(absoluteOutputPath); - - await tar.extract({ - file: archivePath, - cwd: absoluteOutputPath, - strip: 1, + logger.log(`Repacking ${target.name} into dist workspace`); + const absoluteOutputPath = resolvePath( + workspaceDir, + relativePath(targetPaths.rootDir, target.dir), + ); + await packToDirectory({ + packageDir: target.dir, + packageName: target.name, + targetDir: absoluteOutputPath, + archivePath: resolvePath(workspaceDir, archive), + logger, }); - await fs.remove(archivePath); // We remove the dependencies from package.json of packages that are marked // as bundled, so that yarn doesn't try to install them. @@ -372,3 +400,28 @@ async function moveToDistWorkspace( }, }); } + +/** + * Resolves the full set of local (workspace) packages that the given + * package names transitively depend on via `publishedLocalDependencies`. + * Packages marked as `bundled` have their own dependencies excluded. + * + * This is the same traversal that `createDistWorkspace` performs internally. + * Callers must supply the monorepo package list obtained from + * `PackageGraph.listTargetPackages()`. + */ +export async function resolveLocalDependencies( + packageNames: string[], + packages: BackstagePackage[], +): Promise { + const packageGraph = PackageGraph.fromPackages(packages); + const targetNames = packageGraph.collectPackageNames(packageNames, node => { + // Don't include dependencies of packages that are marked as bundled + if (node.packageJson.bundled) { + return undefined; + } + + return node.publishedLocalDependencies.keys(); + }); + return Array.from(targetNames).map(name => packageGraph.get(name)!); +} diff --git a/packages/cli-module-build/src/lib/packager/index.ts b/packages/cli-module-build/src/lib/packager/index.ts index 75f3fdf71d..4b382d0340 100644 --- a/packages/cli-module-build/src/lib/packager/index.ts +++ b/packages/cli-module-build/src/lib/packager/index.ts @@ -14,4 +14,8 @@ * limitations under the License. */ -export { createDistWorkspace } from './createDistWorkspace'; +export { + createDistWorkspace, + packToDirectory, + resolveLocalDependencies, +} from './createDistWorkspace';