feat(cli): contribute a bundle command for dynamic loading
for both frontend and backend plugins. Signed-off-by: David Festal <dfestal@redhat.com> Assisted-by: Cursor
This commit is contained in:
@@ -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.
|
||||
@@ -38,6 +38,7 @@ The `package` command category, `yarn backstage-cli package --help`:
|
||||
```text
|
||||
start [options] Start a package for local development
|
||||
build [options] Build a package for production deployment or publishing
|
||||
bundle [options] Bundle a plugin for dynamic loading (backend or frontend)
|
||||
lint [options] [directories...] Lint a package
|
||||
test Run tests, forwarding args to Jest, defaulting to watch mode
|
||||
clean Delete cache directories
|
||||
@@ -204,6 +205,121 @@ Options:
|
||||
--module-federation Build a package as a module federation remote. Applies to frontend plugin packages only.
|
||||
```
|
||||
|
||||
## package bundle
|
||||
|
||||
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/<mangled-package-name>/)
|
||||
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 <output-dir> [packages...] --alwaysPack
|
||||
# Then pass <output-dir> 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 <dir> Directory in which the bundle subdirectory is created.
|
||||
Defaults to the current package directory.
|
||||
--output-name <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 <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 Structure
|
||||
|
||||
The bundle is created in a directory named after the package. For example,
|
||||
`@myorg/plugin-foo` creates `myorg-plugin-foo/` with the following structure:
|
||||
|
||||
**Backend plugins:**
|
||||
|
||||
```text
|
||||
myorg-plugin-foo/
|
||||
├── package.json # Customized for dynamic loading
|
||||
├── dist/ # Built plugin code
|
||||
│ ├── index.cjs.js
|
||||
│ └── .config-schema.json # Config schema (local packages only)
|
||||
├── embedded/ # Embedded workspace packages (if any)
|
||||
│ └── myorg-plugin-foo-common/
|
||||
│ ├── package.json
|
||||
│ └── dist/
|
||||
└── node_modules/ # All production dependencies
|
||||
```
|
||||
|
||||
**Frontend plugins** produce module federation assets at the bundle root (no `embedded/` or `node_modules/` unless `--pre-packed-dir` is used).
|
||||
|
||||
The `.config-schema.json` contains merged config schemas from the plugin and any
|
||||
embedded workspace packages. It excludes schemas from npm dependencies, as those
|
||||
should be provided by the host application. The `package.json` is updated to
|
||||
reference this generated JSON file instead of any original `.d.ts` schema.
|
||||
|
||||
### 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 +530,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] <workspace-dir>
|
||||
Usage: backstage-cli build-workspace [options] <workspace-dir> [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
|
||||
|
||||
@@ -38,6 +38,7 @@ Options:
|
||||
|
||||
Commands:
|
||||
build
|
||||
bundle
|
||||
clean
|
||||
help [command]
|
||||
postpack
|
||||
@@ -60,6 +61,22 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-build package bundle`
|
||||
|
||||
```
|
||||
Usage: @backstage/cli-module-build package bundle
|
||||
|
||||
Options:
|
||||
--clean
|
||||
--no-build
|
||||
--no-install
|
||||
--output-destination <string>
|
||||
--output-name <string>
|
||||
--pre-packed-dir <string>
|
||||
--verbose
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli-module-build package clean`
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
!dist*
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@scope/foo-node",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
module.exports = { default: { $$type: '@backstage/BackendFeature' } };
|
||||
+8
@@ -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"
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@scope/plugin-foo-common",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@scope/foo-web",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@scope/plugin-foo-react",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js"
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@scope/plugin-foo",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"dependencies": {
|
||||
"@scope/plugin-foo-react": "1.0.0"
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
// Module Federation remote entry
|
||||
+33
@@ -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"
|
||||
}
|
||||
}
|
||||
+17
@@ -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 {};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <output-dir> [packages...] --alwaysPack`
|
||||
* and `<output-dir>` 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<void> {
|
||||
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<string, string> = {};
|
||||
|
||||
// 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<string, unknown>,
|
||||
targetDir: string,
|
||||
pluginType: 'frontend' | 'backend',
|
||||
rootResolutions: Record<string, string> | undefined,
|
||||
embeddedResolutions: Record<string, string>,
|
||||
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<string, string> = {};
|
||||
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<string, string> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
const visited = new Set<string>();
|
||||
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<void>(r => logStream.end(r));
|
||||
|
||||
return { logger, logRunOutput, close, path: logFilePath };
|
||||
}
|
||||
|
||||
async function showLogOnError(
|
||||
logFilePath: string,
|
||||
verbose: boolean,
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
@@ -33,6 +33,15 @@ 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.',
|
||||
execute: { loader: () => import('./commands/package/bundle') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'start'],
|
||||
description: 'Start a package for local development',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<PackageGraphNode[]> {
|
||||
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)!);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createDistWorkspace } from './createDistWorkspace';
|
||||
export {
|
||||
createDistWorkspace,
|
||||
packToDirectory,
|
||||
resolveLocalDependencies,
|
||||
} from './createDistWorkspace';
|
||||
|
||||
@@ -317,6 +317,7 @@ Options:
|
||||
|
||||
Commands:
|
||||
build
|
||||
bundle
|
||||
clean
|
||||
help [command]
|
||||
lint
|
||||
@@ -341,6 +342,22 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli package bundle`
|
||||
|
||||
```
|
||||
Usage: backstage-cli package bundle
|
||||
|
||||
Options:
|
||||
--clean
|
||||
--no-build
|
||||
--no-install
|
||||
--output-destination <string>
|
||||
--output-name <string>
|
||||
--pre-packed-dir <string>
|
||||
--verbose
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli package clean`
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user