From 2598b827ddc4cc6e2ee7759cfad39790cf42c442 Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Tue, 30 Jun 2026 15:10:59 -0400 Subject: [PATCH 1/5] chore: update backend plugin export to use bundler Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- .../export-dynamic-plugin/backend-utils.ts | 179 --- src/commands/export-dynamic-plugin/backend.ts | 1094 ----------------- src/commands/export-dynamic-plugin/command.ts | 93 +- .../export-dynamic-plugin/frontend.ts | 2 +- src/commands/export-dynamic-plugin/utils.ts | 344 ++++++ src/commands/index.ts | 56 +- src/generated/backstage-cli-types.d.ts | 14 + 7 files changed, 465 insertions(+), 1317 deletions(-) delete mode 100644 src/commands/export-dynamic-plugin/backend-utils.ts delete mode 100644 src/commands/export-dynamic-plugin/backend.ts create mode 100644 src/commands/export-dynamic-plugin/utils.ts diff --git a/src/commands/export-dynamic-plugin/backend-utils.ts b/src/commands/export-dynamic-plugin/backend-utils.ts deleted file mode 100644 index 4efa99c..0000000 --- a/src/commands/export-dynamic-plugin/backend-utils.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2024 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 * as fs from 'fs-extra'; -// @ts-ignore -import isNative from 'is-native-module'; -import semver from 'semver'; - -import path from 'path'; - -import { Task } from '../../lib/tasks'; - -export function addToDependenciesForModule({ - dependency, - dependencies, - ignoreVersionCheck = [], - module, -}: { - dependency: { name: string; version: string }; - dependencies: { [key: string]: string }; - ignoreVersionCheck?: string[]; - module: string; -}): void { - const existingDependencyVersion = dependencies[dependency.name]; - if (existingDependencyVersion === undefined) { - dependencies[dependency.name] = dependency.version; - return; - } - - if (existingDependencyVersion === dependency.version) { - return; - } - - const existingDependencyMinVersion = semver.minVersion( - existingDependencyVersion, - ); - if ( - existingDependencyMinVersion && - semver.satisfies(existingDependencyMinVersion, dependency.version) - ) { - Task.log( - `Several compatible versions ('${existingDependencyVersion}', '${dependency.version}') of the same transitive dependency ('${dependency.name}') for embedded module ('${module}'): keeping '${existingDependencyVersion}'`, - ); - return; - } - const newDependencyMinVersion = semver.minVersion(dependency.version); - if ( - newDependencyMinVersion && - semver.satisfies(newDependencyMinVersion, existingDependencyVersion) - ) { - dependencies[dependency.name] = dependency.version; - Task.log( - `Several compatible versions ('${existingDependencyVersion}', '${dependency.version}') of the same transitive dependency ('${dependency.name}') for embedded module ('${module}'): keeping '${dependency.version}'`, - ); - return; - } - if (!ignoreVersionCheck.includes(dependency.name)) { - throw new Error( - `Several incompatible versions ('${existingDependencyVersion}', '${dependency.version}') of the same transitive dependency ('${dependency.name}') for embedded module ('${module}')`, - ); - } else { - Task.log( - `Several incompatible versions ('${existingDependencyVersion}', '${dependency.version}') of the same transitive dependency ('${dependency.name}') for embedded module ('${module}') however this has been overridden to use '${dependency.version}'`, - ); - } -} - -export function addToMainDependencies( - dependenciesToAdd: { [key: string]: string }, - mainDependencies: { [key: string]: string }, - ignoreVersionCheck: string[] = [], -): void { - for (const dep in dependenciesToAdd) { - if (!Object.prototype.hasOwnProperty.call(dependenciesToAdd, dep)) { - continue; - } - const existingVersion = mainDependencies[dep]; - if (existingVersion === undefined) { - mainDependencies[dep] = dependenciesToAdd[dep]; - continue; - } - if (existingVersion !== dependenciesToAdd[dep]) { - const existingMinVersion = semver.minVersion(existingVersion); - if ( - existingMinVersion && - semver.satisfies(existingMinVersion, dependenciesToAdd[dep]) - ) { - Task.log( - `The version of a dependency ('${dep}') of an embedded module differs from the main module's dependencies: '${dependenciesToAdd[dep]}', '${existingVersion}': keeping it as it is compatible`, - ); - continue; - } - if (ignoreVersionCheck.includes(dep)) { - Task.log( - `The version of a dependency ('${dep}') of an embedded module conflicts with the main module's dependencies: '${dependenciesToAdd[dep]}', '${existingVersion}': however this has been overridden`, - ); - } else { - throw new Error( - `The version of a dependency ('${dep}') of an embedded module conflicts with main module dependencies: '${dependenciesToAdd[dep]}', '${existingVersion}': cannot proceed!`, - ); - } - } - } -} - -export function isValidPluginModule(pluginModule: any): boolean { - return ( - isBackendFeature(pluginModule?.default) || - isBackendFeatureFactory(pluginModule?.default) || - isBackendDynamicPluginInstaller(pluginModule?.dynamicPluginInstaller) - ); -} - -function isBackendFeature(value: unknown): boolean { - return ( - !!value && - (typeof value === 'object' || typeof value === 'function') && - (value as any).$$type === '@backstage/BackendFeature' - ); -} - -function isBackendFeatureFactory(value: unknown): boolean { - return ( - !!value && - typeof value === 'function' && - (value as any).$$type === '@backstage/BackendFeatureFactory' - ); -} - -function isBackendDynamicPluginInstaller(obj: unknown): boolean { - return ( - obj !== undefined && - typeof obj === 'object' && - obj !== null && - 'kind' in obj && - obj.kind === 'new' && - 'install' in obj && - typeof obj.install === 'function' - ); -} - -export async function* gatherNativeModules( - pkgPath: string, -): AsyncGenerator { - if (await fs.pathExists(path.join(pkgPath, 'package.json'))) { - yield* (async function* anaylzePackageJson() { - const pkg = JSON.parse( - (await fs.readFile(path.join(pkgPath, 'package.json'))).toString( - 'utf8', - ), - ); - if (isNative(pkg)) { - yield pkg.name || pkgPath; - } - })(); - if (await fs.pathExists(path.join(pkgPath, 'node_modules'))) { - yield* gatherNativeModules(path.join(pkgPath, 'node_modules')); - } - } else { - for (const file of await fs.readdir(pkgPath)) { - if ((await fs.stat(path.join(pkgPath, file))).isDirectory()) { - yield* gatherNativeModules(path.join(pkgPath, file)); - } - } - } -} diff --git a/src/commands/export-dynamic-plugin/backend.ts b/src/commands/export-dynamic-plugin/backend.ts deleted file mode 100644 index e9cbca4..0000000 --- a/src/commands/export-dynamic-plugin/backend.ts +++ /dev/null @@ -1,1094 +0,0 @@ -/* - * Copyright 2024 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 } from '@backstage/cli-node'; - -import { getPackages, Packages } from '@manypkg/get-packages'; -import { parseSyml } from '@yarnpkg/parsers'; -import chalk from 'chalk'; -import { OptionValues } from 'commander'; -import * as fs from 'fs-extra'; -import * as semver from 'semver'; - -import { execSync } from 'child_process'; -import { createRequire } from 'node:module'; -import os from 'node:os'; -import * as path from 'path'; - -import { - isBackstageVersionSpec, - resolveBackstageVersion, -} from '../../lib/backstageVersion'; -import { productionPack } from '../../lib/packager/productionPack'; -import { paths } from '../../lib/paths'; -import { Task } from '../../lib/tasks'; -import { Lockfile } from '../../lib/versioning'; -import { - addToDependenciesForModule, - addToMainDependencies, - gatherNativeModules, - isValidPluginModule, -} from './backend-utils'; - -export async function backend(opts: OptionValues): Promise { - const targetRelativePath = 'dist-dynamic'; - const target = path.join(paths.targetDir, targetRelativePath); - const yarn = 'yarn'; - const yarnVersion = execSync(`${yarn} --version`).toString().trim(); // NOSONAR - - const pkgContent = await fs.readFile( - paths.resolveTarget('package.json'), - 'utf8', - ); - const pkg = JSON.parse(pkgContent) as BackstagePackageJson; - if (pkg.bundled) { - throw new Error( - `Packages exported as dynamic backend plugins should not have the ${chalk.cyan( - 'bundled', - )} field set to ${chalk.cyan('true')}`, - ); - } - - const derivedPackageName = `${pkg.name}-dynamic`; - const packagesToEmbed = (opts.embedPackage || []) as string[]; - const allowNative = (opts.allowNativePackage || []) as string[]; - const suppressNative = (opts.suppressNativePackage || []) as string[]; - const ignoreVersionCheck = (opts.ignoreVersionCheck || []) as string[]; - const monoRepoPackages = await getPackages(paths.targetDir); - const embeddedResolvedPackages = await searchEmbedded( - pkg, - packagesToEmbed, - monoRepoPackages, - createRequire(path.join(paths.targetDir, 'package.json')), - [], - ); - const embeddedPackages = embeddedResolvedPackages.map(e => e.packageName); - const unusedEmbeddedPackages = packagesToEmbed.filter( - e => !embeddedPackages.includes(e), - ); - if (unusedEmbeddedPackages.length > 0) { - Task.log( - chalk.yellow( - `Some embedded packages are not part of the plugin transitive dependencies:${chalk.cyan( - ['', ...unusedEmbeddedPackages].join('\n- '), - )}`, - ), - ); - } - - const stringOrRegexp = (s: string) => - s.startsWith('/') && s.endsWith('/') ? new RegExp(s.slice(1, -1)) : s; - - const moveToPeerDependencies = [ - /@backstage\//, - ...((opts.sharedPackage || []) as string[]) - .filter(p => !p.startsWith('!')) - .map(stringOrRegexp), - ]; - - const dontMoveToPeerDependencies = ((opts.sharedPackage || []) as string[]) - .filter(p => p.startsWith('!')) - .map(p => p.slice(1)) - .map(stringOrRegexp); - dontMoveToPeerDependencies.push(...embeddedPackages); - - const sharedPackagesRules: SharedPackagesRules = { - include: moveToPeerDependencies, - exclude: dontMoveToPeerDependencies, - }; - - if (opts.clean) { - await fs.remove(target); - } - - await fs.mkdirs(target); - await fs.writeFile( - path.join(target, '.gitignore'), - ` -* -${ - opts.trackDynamicManifestAndLockFile - ? ` -!package.json -!yarn.lock -` - : '' -}`, - ); - - if (suppressNative.length > 0) { - for (const toSuppress of suppressNative) { - await fs.mkdirs(path.join(target, 'embedded', toSuppress)); - await fs.writeFile( - path.join(target, 'embedded', toSuppress, 'package.json'), - JSON.stringify( - { - name: toSuppress, - main: 'index.js', - }, - undefined, - 2, - ), - ); - await fs.writeFile( - path.join(target, 'embedded', toSuppress, 'index.js'), - ` -throw new Error( - 'The package "${toSuppress}" has been marked as a native module and removed from this dynamic plugin package "${derivedPackageName}", as native modules are not currently supported by dynamic plugins' -);`, - ); - } - } - - const embeddedPeerDependencies: { - [key: string]: string; - } = {}; - - for (const embedded of embeddedResolvedPackages) { - const embeddedDestRelativeDir = embeddedPackageRelativePath(embedded); - const embeddedDestDir = path.join(target, embeddedDestRelativeDir); - if (!embedded.alreadyPacked) { - if (opts.build) { - Task.log( - `Building embedded package ${chalk.cyan( - embedded.packageName, - )} in ${chalk.cyan(embedded.dir)}`, - ); - await Task.forCommand(`${yarn} build`, { - cwd: embedded.dir, - optional: false, - }); - } - Task.log( - `Packing embedded package ${chalk.cyan( - embedded.packageName, - )} in ${chalk.cyan(embedded.dir)} to ${chalk.cyan( - embeddedDestRelativeDir, - )}`, - ); - await productionPack({ - packageDir: embedded.dir, - targetDir: embeddedDestDir, - }); - } else { - Task.log( - `Copying embedded package ${chalk.cyan( - embedded.packageName, - )} from ${chalk.cyan(embedded.dir)} to ${chalk.cyan( - embeddedDestRelativeDir, - )}`, - ); - fs.rmSync(embeddedDestDir, { force: true, recursive: true }); - fs.cpSync(embedded.dir, embeddedDestDir, { recursive: true }); - } - - // Remove the `node_modules` sub-folder of the embedded package, - // if it has been copied (in the case typical case of wrappers). - if (fs.pathExistsSync(path.join(embeddedDestDir, 'node_modules'))) { - fs.rmSync(path.join(embeddedDestDir, 'node_modules'), { - force: true, - recursive: true, - }); - } - - Task.log( - `Customizing embedded package ${chalk.cyan( - embedded.packageName, - )} for dynamic loading`, - ); - await customizeForDynamicUse({ - embedded: embeddedResolvedPackages, - isYarnV1: yarnVersion.startsWith('1.'), - monoRepoPackages, - sharedPackages: sharedPackagesRules, - overridding: { - private: true, - version: `${embedded.version}+embedded`, - }, - after: embeddedPkg => { - if (embeddedPkg.peerDependencies) { - Object.entries(embeddedPkg.peerDependencies).forEach( - ([name, version]) => { - addToDependenciesForModule({ - dependency: { name, version }, - dependencies: embeddedPeerDependencies, - ignoreVersionCheck, - module: embeddedPkg.name, - }); - }, - ); - } - }, - })(path.join(embeddedDestDir, 'package.json')); - } - - const embeddedDependenciesResolutions = embeddedResolvedPackages.reduce( - (resolutions, embeddedPkg) => ({ - ...resolutions, - ...{ - [embeddedPkg.packageName]: `file:./${embeddedPackageRelativePath(embeddedPkg)}`, - }, - }), - {}, - ); - - if (opts.build) { - Task.log(`Building main package`); - await Task.forCommand(`${yarn} build`, { - cwd: paths.targetDir, - optional: false, - }); - } - - Task.log( - `Packing main package to ${chalk.cyan( - path.join(targetRelativePath, 'package.json'), - )}`, - ); - await productionPack({ - packageDir: '', - targetDir: target, - }); - - // Small cleanup in case the `dist-dynamic` entry was still in the `files` of the main plugin package. - if (fs.pathExistsSync(path.join(target, 'dist-dynamic'))) { - fs.rmSync(path.join(target, 'dist-dynamic'), { - force: true, - recursive: true, - }); - } - - Task.log( - `Customizing main package in ${chalk.cyan( - path.join(targetRelativePath, 'package.json'), - )} for dynamic loading`, - ); - await customizeForDynamicUse({ - embedded: embeddedResolvedPackages, - isYarnV1: yarnVersion.startsWith('1.'), - monoRepoPackages, - sharedPackages: sharedPackagesRules, - overridding: { - name: derivedPackageName, - bundleDependencies: true, - // We remove scripts, because they do not make sense for this derived package. - // They even bring errors, especially the pre-pack and post-pack ones: - // we want to be able to use npm pack on this derived package to distribute it as a dynamic plugin, - // and obviously this should not trigger the backstage pre-pack or post-pack actions - // which are related to the packaging of the original static package. - scripts: {}, - }, - additionalResolutions: { - ...embeddedDependenciesResolutions, - ...suppressNative - .map((nativePkg: string) => ({ - [nativePkg]: path.join('file:./embedded', nativePkg), - })) - .reduce((prev, curr) => ({ ...prev, ...curr }), {}), - }, - after(mainPkg) { - if (Object.keys(embeddedPeerDependencies).length === 0) { - return; - } - Task.log( - `Hoisting peer dependencies of embedded packages to the main package`, - ); - const mainPeerDependencies = mainPkg.peerDependencies || {}; - addToMainDependencies( - embeddedPeerDependencies, - mainPeerDependencies, - ignoreVersionCheck, - ); - if (Object.keys(mainPeerDependencies).length > 0) { - mainPkg.peerDependencies = mainPeerDependencies; - } - }, - })(path.resolve(target, 'package.json')); - - const yarnLock = path.resolve(target, 'yarn.lock'); - const yarnLockExists = await fs.pathExists(yarnLock); - - if (!yarnLockExists) { - // Search the yarn.lock of the static plugin, possibly at the root of the monorepo. - - let staticPluginYarnLock: string | undefined; - if (await fs.pathExists(path.join(paths.targetDir, 'yarn.lock'))) { - staticPluginYarnLock = path.join(paths.targetDir, 'yarn.lock'); - } else if (await fs.pathExists(path.join(paths.targetRoot, 'yarn.lock'))) { - staticPluginYarnLock = path.join(paths.targetRoot, 'yarn.lock'); - } - - if (!staticPluginYarnLock) { - throw new Error( - `Could not find the static plugin ${chalk.cyan( - 'yarn.lock', - )} file in either the local folder or the monorepo root (${chalk.cyan( - paths.targetRoot, - )})`, - ); - } - - await fs.copyFile(staticPluginYarnLock, yarnLock); - - if (!opts.install) { - Task.log( - chalk.yellow( - `Last export step (${chalk.cyan( - 'yarn install', - )} has been disabled: the dynamic plugin package ${chalk.cyan( - 'yarn.lock', - )} file will be inconsistent until ${chalk.cyan( - 'yarn install', - )} is run manually`, - ), - ); - } - } - - if (opts.install) { - Task.log(`Installing private dependencies of the main package`); - - const logFile = path.join(os.tmpdir(), 'rhdh-cli.yarn-install.log'); - const redirect = `> ${logFile}`; - const yarnInstall = yarnVersion.startsWith('1.') - ? `${yarn} install --production${ - yarnLockExists ? ' --frozen-lockfile' : '' - } ${redirect}` - : `${yarn} install${yarnLockExists ? ' --immutable' : ' --no-immutable'} ${redirect}`; - - try { - await Task.forCommand(yarnInstall, { cwd: target, optional: false }); - } catch (err) { - if (await fs.pathExists(logFile)) { - const logContents = await fs.readFile(logFile, 'utf8'); - console.error( - chalk.red( - `\n${chalk.bold('yarn install failed. Log output from')} ${chalk.cyan(logFile)}:\n`, - ), - ); - console.error(logContents); - } - throw err; - } - await fs.remove(paths.resolveTarget(targetRelativePath, '.yarn')); - - // Checking if some shared dependencies have been included inside the private dependencies - Task.log(`Validating private dependencies`); - const lockFile = await Lockfile.load(yarnLock); - const sharedPackagesInPrivateDeps: string[] = []; - for (const key of lockFile.keys()) { - const entry = lockFile.get(key); - if (!entry) { - continue; - } - if (`${pkg.name}-dynamic` === key) { - continue; - } - if (embeddedPackages.includes(key)) { - continue; - } - if (isPackageShared(key, sharedPackagesRules)) { - sharedPackagesInPrivateDeps.push(key); - } - } - if (sharedPackagesInPrivateDeps.length > 0) { - // Some shared dependencies have been included inside the private dependencies - // => analyze the yarn.lock file to guess from which direct dependencies they - // were imported. - - const dynamicPkgContent = await fs.readFile( - path.resolve(target, 'package.json'), - 'utf8', - ); - - const dynamicPkg = JSON.parse(dynamicPkgContent) as BackstagePackageJson; - const lockfileContents = await fs.readFile(yarnLock, 'utf8'); - let data: any; - try { - data = parseSyml(lockfileContents); - } catch (err) { - throw new Error(`Failed parsing ${chalk.cyan(yarnLock)}: ${err}`); - } - - const packagesToProbablyEmbed: string[] = []; - for (const dep in dynamicPkg.dependencies || []) { - if ( - !Object.prototype.hasOwnProperty.call(dynamicPkg.dependencies, dep) - ) { - continue; - } - const matchingEntry = Object.entries(data).find(([q, _]) => { - return ( - q.startsWith(`${dep}@`) && - (q.includes(`@${dynamicPkg.dependencies![dep]}`) || - q.includes(`@npm:${dynamicPkg.dependencies![dep]}`)) - ); - }); - - if (matchingEntry) { - const yarnEntry = matchingEntry[1] as any; - if (yarnEntry.dependencies) { - if ( - Object.keys(yarnEntry.dependencies).some(d => { - return isPackageShared(d, sharedPackagesRules); - }) - ) { - packagesToProbablyEmbed.push(dep); - } - } - } - } - - throw new Error( - `Following shared package(s) should not be part of the plugin private dependencies:${chalk.cyan( - ['', ...sharedPackagesInPrivateDeps].join('\n- '), - )}\n\nEither unshare them with the ${chalk.cyan( - '--shared-package !', - )} option, or use the ${chalk.cyan( - '--embed-package', - )} to embed the following packages which use shared dependencies:${chalk.cyan( - ['', ...packagesToProbablyEmbed].join('\n- '), - )}`, - ); - } - - // Check whether private dependencies contain native modules, and fail for now (not supported). - const nativePackages: string[] = []; - for await (const nativePkg of gatherNativeModules(target)) { - if (!allowNative.includes(nativePkg)) { - nativePackages.push(nativePkg); - } - } - - if (nativePackages.length > 0) { - throw new Error( - `Dynamic plugins do not support native plugins. the following native modules have been transitively detected:${chalk.cyan( - ['', ...nativePackages].join('\n- '), - )}`, - ); - } - - // Check that the backend plugin provides expected entrypoints. - Task.log(`Validating plugin entry points`); - const validateEntryPointsError = validatePluginEntryPoints(target); - if (validateEntryPointsError) { - throw new Error(validateEntryPointsError); - } - // everything is fine, remove the yarn install log - await fs.remove(paths.resolveTarget(targetRelativePath, logFile)); - } - return target; -} - -type ResolvedEmbedded = { - packageName: string; - version: string; - dir: string; - parentPackageName: string; - alreadyPacked: boolean; -}; - -async function searchEmbedded( - pkg: BackstagePackageJson, - packagesToEmbed: string[], - monoRepoPackages: Packages, - req: NodeRequire, - alreadyResolved: ResolvedEmbedded[], -): Promise { - const embedded = [...packagesToEmbed]; - let regex: RegExp | undefined = undefined; - switch (pkg.backstage?.role) { - case 'backend-plugin': - regex = /-backend$/; - break; - case 'backend-plugin-module': - regex = /-backend-module-.+$/; - break; - case 'node-library': - regex = /-node$/; - break; - default: - } - if (regex) { - const commonPackage = pkg.name.replace(regex, '-common'); - if ( - commonPackage !== pkg.name && - !alreadyResolved.find(r => r.packageName === commonPackage) - ) { - embedded.push(commonPackage); - } - const nodePackage = pkg.name.replace(regex, '-node'); - if ( - nodePackage !== pkg.name && - !alreadyResolved.find(r => r.packageName === nodePackage) - ) { - embedded.push(nodePackage); - } - } - - const resolved: ResolvedEmbedded[] = []; - if (pkg.dependencies) { - for (const dep in pkg.dependencies || []) { - if (!Object.prototype.hasOwnProperty.call(pkg.dependencies, dep)) { - continue; - } - - if (embedded.includes(dep)) { - const dependencyVersion = pkg.dependencies[dep]; - - let effectiveVersion = dependencyVersion; - if (isBackstageVersionSpec(dependencyVersion)) { - const resolvedBackstageVersion = await resolveBackstageVersion( - dep, - dependencyVersion, - ); - if (resolvedBackstageVersion) { - effectiveVersion = resolvedBackstageVersion; - } - } - - const relatedMonoRepoPackages = monoRepoPackages.packages.filter( - p => p.packageJson.name === dep, - ); - if (relatedMonoRepoPackages.length > 1) { - throw new Error( - `Two packages named '${dep}' exist in the monorepo structure: this is not supported.`, - ); - } - - if ( - relatedMonoRepoPackages.length === 0 && - dependencyVersion.startsWith('workspace:') - ) { - throw new Error( - `No package named '${dep}' exist in the monorepo structure.`, - ); - } - - let resolvedPackage: BackstagePackageJson | undefined; - let resolvedPackageDir: string; - let alreadyPacked = false; - if (relatedMonoRepoPackages.length === 1) { - const monoRepoPackage = relatedMonoRepoPackages[0]; - - let isResolved: boolean = false; - if (dependencyVersion.startsWith('workspace:')) { - isResolved = checkWorkspacePackageVersion(dependencyVersion, { - dir: monoRepoPackage.dir, - version: monoRepoPackage.packageJson.version, - }); - } else if ( - semver.satisfies( - monoRepoPackage.packageJson.version, - effectiveVersion, - ) - ) { - isResolved = true; - } - - if (!isResolved) { - throw new Error( - `Monorepo package named '${dep}' at '${monoRepoPackage.dir}' doesn't satisfy dependency version requirement in parent package '${pkg.name}'.`, - ); - } - resolvedPackage = JSON.parse( - await fs.readFile( - paths.resolveTarget( - path.join(monoRepoPackage.dir, 'package.json'), - ), - 'utf8', - ), - ) as BackstagePackageJson; - resolvedPackageDir = monoRepoPackage.dir; - } else { - // Not found as a source package in the monorepo (if any). - // Let's try to find the package through a require call. - const resolvedPackageJson = req.resolve( - path.join(dep, 'package.json'), - ); - resolvedPackageDir = path.dirname(resolvedPackageJson); - resolvedPackage = JSON.parse( - await fs.readFile(resolvedPackageJson, 'utf8'), - ) as BackstagePackageJson; - - if (!semver.satisfies(resolvedPackage.version, effectiveVersion)) { - throw new Error( - `Resolved package named '${dep}' at '${resolvedPackageDir}' doesn't satisfy dependency version requirement in parent package '${pkg.name}': '${resolvedPackage.version}', '${dependencyVersion}'.`, - ); - } - alreadyPacked = !resolvedPackage.main?.endsWith('.ts'); - } - - if (resolvedPackage.bundled) { - throw new Error( - 'Packages embedded inside dynamic backend plugins should not have the "bundled" field set to true', - ); - } - - if ( - ![...alreadyResolved, ...resolved].find( - p => p.dir === resolvedPackageDir, - ) - ) { - resolved.push({ - dir: resolvedPackageDir, - packageName: resolvedPackage.name, - version: resolvedPackage.version ?? '0.0.0', - parentPackageName: pkg.name, - alreadyPacked, - }); - // scan for embedded packages under the resolved package - resolved.push( - ...(await searchEmbedded( - resolvedPackage, - embedded, - monoRepoPackages, - createRequire(path.join(resolvedPackageDir, 'package.json')), - [...alreadyResolved, ...resolved], - )), - ); - } - } - } - } - return resolved; -} - -function checkWorkspacePackageVersion( - requiredVersionSpec: string, - pkg: { version: string; dir: string }, -): boolean { - const versionDetail = requiredVersionSpec.replace(/^workspace:/, ''); - - return ( - pkg.dir === versionDetail || - versionDetail === '*' || - versionDetail === '~' || - versionDetail === '^' || - semver.satisfies(pkg.version, versionDetail) - ); -} - -/** - * Resolves workspace: and backstage: protocol version specs to concrete versions. - * - * - workspace:^ / workspace:~ / workspace:* => lookup in embedded, then monoRepoPackages - * - backstage:^ => delegate to resolveBackstageVersion() - * - anything else => undefined (no transformation needed) - * - * Preserves the range prefix: workspace:^ => ^1.5.0, workspace:~ => ~1.5.0, - * workspace:* => 1.5.0, backstage:^ => ^1.5.0. - */ -async function resolveProtocolVersion( - dep: string, - versionSpec: string, - embedded: ResolvedEmbedded[], - monoRepoPackages: Packages | undefined, - contextPkgName: string, -): Promise<{ resolved: string; exact: string } | undefined> { - if (versionSpec.startsWith('workspace:')) { - let resolvedVersion: string | undefined; - const rangeSpecifier = versionSpec.replace(/^workspace:/, ''); - const embeddedDep = embedded.find( - e => - e.packageName === dep && checkWorkspacePackageVersion(versionSpec, e), - ); - if (embeddedDep) { - resolvedVersion = embeddedDep.version; - } else if (monoRepoPackages) { - const relatedMonoRepoPackages = monoRepoPackages.packages.filter( - p => p.packageJson.name === dep, - ); - if (relatedMonoRepoPackages.length > 1) { - throw new Error( - `Two packages named ${chalk.cyan( - dep, - )} exist in the monorepo structure: this is not supported.`, - ); - } - if ( - relatedMonoRepoPackages.length === 1 && - checkWorkspacePackageVersion(versionSpec, { - dir: relatedMonoRepoPackages[0].dir, - version: relatedMonoRepoPackages[0].packageJson.version, - }) - ) { - resolvedVersion = relatedMonoRepoPackages[0].packageJson.version; - } - } - - if (!resolvedVersion) { - throw new Error( - `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( - contextPkgName, - )} doesn't exist in the monorepo structure: maybe you should embed it ?`, - ); - } - - const exact = resolvedVersion; - if (rangeSpecifier === '^' || rangeSpecifier === '~') { - resolvedVersion = rangeSpecifier + resolvedVersion; - } - return { resolved: resolvedVersion, exact }; - } - - if (isBackstageVersionSpec(versionSpec)) { - const resolvedVersion = await resolveBackstageVersion(dep, versionSpec); - if (resolvedVersion) { - Task.log( - ` resolving ${chalk.cyan(dep)} from ${chalk.yellow( - versionSpec, - )} to ${chalk.green(resolvedVersion)}`, - ); - const exact = resolvedVersion.replace(/^[\^~]/, ''); - return { resolved: resolvedVersion, exact }; - } - } - - return undefined; -} - -export function customizeForDynamicUse(options: { - embedded: ResolvedEmbedded[]; - isYarnV1: boolean; - monoRepoPackages: Packages | undefined; - sharedPackages?: SharedPackagesRules | undefined; - overridding?: - | (Partial & { - bundleDependencies?: boolean; - }) - | undefined; - additionalOverrides?: { [key: string]: any } | undefined; - additionalResolutions?: { [key: string]: any } | undefined; - after?: ((pkg: BackstagePackageJson) => void) | undefined; -}): (dynamicPkgPath: string) => Promise { - return async (dynamicPkgPath: string): Promise => { - const dynamicPkgContent = await fs.readFile(dynamicPkgPath, 'utf8'); - const pkgToCustomize = JSON.parse( - dynamicPkgContent, - ) as BackstagePackageJson; - - for (const field in options.overridding || {}) { - if (!Object.prototype.hasOwnProperty.call(options.overridding, field)) { - continue; - } - (pkgToCustomize as any)[field] = (options.overridding as any)[field]; - } - - pkgToCustomize.files = pkgToCustomize.files?.filter( - f => !f.startsWith('dist-dynamic/'), - ); - - // Collect exact versions for pinning in resolutions - const pinnedResolutions: Record = {}; - const embeddedNames = new Set(options.embedded.map(e => e.packageName)); - - // Resolve workspace: and backstage: in dependencies - if (pkgToCustomize.dependencies) { - for (const dep in pkgToCustomize.dependencies) { - if ( - !Object.prototype.hasOwnProperty.call( - pkgToCustomize.dependencies, - dep, - ) - ) { - continue; - } - - const dependencyVersionSpec = pkgToCustomize.dependencies[dep]; - const result = await resolveProtocolVersion( - dep, - dependencyVersionSpec, - options.embedded, - options.monoRepoPackages, - pkgToCustomize.name, - ); - if (result) { - pkgToCustomize.dependencies[dep] = result.resolved; - if (!embeddedNames.has(dep)) { - pinnedResolutions[dep] = result.exact; - } - } - - if (isPackageShared(dep, options.sharedPackages)) { - Task.log(` moving ${chalk.cyan(dep)} to peerDependencies`); - - pkgToCustomize.peerDependencies ||= {}; - pkgToCustomize.peerDependencies[dep] = - pkgToCustomize.dependencies[dep]; - delete pkgToCustomize.dependencies[dep]; - - continue; - } - - // If yarn v1, then detect if the current dep is an embedded one, - // and if it is the case replace the version by the file protocol - // (like what we do for the resolutions). - if (options.isYarnV1) { - const embeddedDep = options.embedded.find( - e => - e.packageName === dep && - checkWorkspacePackageVersion(dependencyVersionSpec, e), - ); - if (embeddedDep) { - pkgToCustomize.dependencies[dep] = - `file:./${embeddedPackageRelativePath(embeddedDep)}`; - } - } - } - } - - // Resolve workspace: and backstage: in pre-existing peerDependencies - if (pkgToCustomize.peerDependencies) { - for (const dep in pkgToCustomize.peerDependencies) { - if ( - !Object.prototype.hasOwnProperty.call( - pkgToCustomize.peerDependencies, - dep, - ) - ) - continue; - const result = await resolveProtocolVersion( - dep, - pkgToCustomize.peerDependencies[dep], - options.embedded, - options.monoRepoPackages, - pkgToCustomize.name, - ); - if (result) { - pkgToCustomize.peerDependencies[dep] = result.resolved; - if (!embeddedNames.has(dep)) { - pinnedResolutions[dep] = result.exact; - } - } - } - } - - // Pin transitive workspace:/backstage: deps reachable from direct deps. - // Without pinning, yarn resolves these from npm (workspace: lockfile entries - // cannot seed npm: entries in dist-dynamic), causing version drift. - if (options.monoRepoPackages) { - const wsPackagesByName = new Map( - options.monoRepoPackages.packages.map(p => [p.packageJson.name, p]), - ); - const queue = Object.keys(pinnedResolutions); - const visited = new Set(queue); - - while (queue.length > 0) { - const pkgName = queue.pop()!; - const wsPkg = wsPackagesByName.get(pkgName); - if (!wsPkg) continue; - - const allDeps: Record = { - ...wsPkg.packageJson.dependencies, - ...wsPkg.packageJson.peerDependencies, - }; - for (const [depName, depVersion] of Object.entries(allDeps)) { - if (visited.has(depName)) continue; - visited.add(depName); - - const isProtocol = - depVersion.startsWith('workspace:') || - depVersion.startsWith('backstage:'); - if (!isProtocol) continue; - if (embeddedNames.has(depName)) continue; - - const depPkg = wsPackagesByName.get(depName); - if (depPkg) { - pinnedResolutions[depName] = depPkg.packageJson.version; - queue.push(depName); - } - } - } - } - - // We remove devDependencies here since we want the dynamic plugin derived package - // to get only production dependencies, and no transitive dependencies, in both - // the node_modules sub-folder and yarn.lock file in `dist-dynamic`. - // - // And it happens that `yarn install --production` (yarn 1) doesn't completely - // remove devDependencies as needed. - // - // See https://github.com/yarnpkg/yarn/issues/6373#issuecomment-760068356 - pkgToCustomize.devDependencies = {}; - - // additionalOverrides and additionalResolutions will override the - // current package.json entries for "overrides" and "resolutions" - // respectively - const overrides = (pkgToCustomize as any).overrides || {}; - (pkgToCustomize as any).overrides = { - // The following lines are a workaround for the fact that the @aws-sdk/util-utf8-browser package - // is not compatible with the NPM 9+, so that `npm pack` would not grab the Javascript files. - // This package has been deprecated in favor of @smithy/util-utf8. - // - // See https://github.com/aws/aws-sdk-js-v3/issues/5305. - '@aws-sdk/util-utf8-browser': { - '@smithy/util-utf8': '^2.0.0', - }, - ...overrides, - ...(options.additionalOverrides || {}), - }; - // Pin resolved workspace:/backstage: packages to exact versions in resolutions - // to prevent npm version drift during yarn install --no-immutable. - // Existing resolutions and additionalResolutions (embedded file: refs) take precedence. - const resolutions = (pkgToCustomize as any).resolutions || {}; - (pkgToCustomize as any).resolutions = { - // The following lines are a workaround for the fact that the @aws-sdk/util-utf8-browser package - // is not compatible with the NPM 9+, so that `npm pack` would not grab the Javascript files. - // This package has been deprecated in favor of @smithy/util-utf8. - // - // See https://github.com/aws/aws-sdk-js-v3/issues/5305. - '@aws-sdk/util-utf8-browser': 'npm:@smithy/util-utf8@~2', - ...pinnedResolutions, - ...resolutions, - ...(options.additionalResolutions || {}), - }; - - if (options.after) { - options.after(pkgToCustomize); - } - - await fs.writeJson(dynamicPkgPath, pkgToCustomize, { - encoding: 'utf8', - spaces: 2, - }); - }; -} - -type SharedPackagesRules = { - include: (string | RegExp)[]; - exclude: (string | RegExp)[]; -}; - -function isPackageShared( - pkgName: string, - rules: SharedPackagesRules | undefined, -) { - function test(str: string, expr: string | RegExp): boolean { - if (typeof expr === 'string') { - return str === expr; - } - return expr.test(str); - } - - if ((rules?.exclude || []).some(dontMove => test(pkgName, dontMove))) { - return false; - } - - if ((rules?.include || []).some(move => test(pkgName, move))) { - return true; - } - - return false; -} - -function validatePluginEntryPoints(target: string): string { - const dynamicPluginRequire = createRequire(`${target}/package.json`); - - let retryingAfterTsExtensionAdded = false; - function requireModule(modulePath: string): any { - try { - return dynamicPluginRequire(modulePath); - } catch (e) { - // Retry only if we failed with SyntaxError or unsupported dir format - // because the `ts` require extension was not there. Else we should - // throw. - if ( - retryingAfterTsExtensionAdded || - dynamicPluginRequire.extensions['.ts'] !== undefined - ) { - throw e; - } - } - - retryingAfterTsExtensionAdded = true; - Task.log( - ` adding typescript extension support to enable entry point validation`, - ); - - let nodeTransform: string | undefined; - try { - nodeTransform = dynamicPluginRequire.resolve( - '@backstage/cli/config/nodeTransform.cjs', - ); - } catch (e) { - Task.log( - ` => unable to find '@backstage/cli/config/nodeTransform.cjs' in the plugin context`, - ); - } - - if (nodeTransform) { - dynamicPluginRequire(nodeTransform); - } else { - Task.log( - ` => searching for 'ts-node' (legacy mode before backage 1.24.0)`, - ); - - let tsNode: string | undefined; - try { - tsNode = dynamicPluginRequire.resolve('ts-node'); - } catch (e) { - Task.log(` => unable to find 'ts-node' in the plugin context`); - } - - if (tsNode) { - dynamicPluginRequire(tsNode).register({ - transpileOnly: true, - project: path.resolve(paths.targetRoot, 'tsconfig.json'), - compilerOptions: { - module: 'CommonJS', - }, - }); - } - } - - // Retry requiring the plugin main module after adding typescript extensions - return dynamicPluginRequire(modulePath); - } - - try { - const pluginModule = requireModule(target); - const alphaPackage = path.resolve(target, 'alpha'); - const pluginAlphaModule = fs.pathExistsSync(alphaPackage) - ? requireModule(alphaPackage) - : undefined; - - if ( - ![pluginAlphaModule, pluginModule] - .filter(m => m !== undefined) - .some(isValidPluginModule) - ) { - return `Backend plugin is not valid for dynamic loading: it should either export a ${chalk.cyan( - 'BackendFeature', - )} or ${chalk.cyan( - 'BackendFeatureFactory', - )} as default export, or export a ${chalk.cyan( - 'const dynamicPluginInstaller: BackendDynamicPluginInstaller', - )} field as dynamic loading entrypoint`; - } - } catch (e) { - return `Unable to validate plugin entry points: ${e}`; - } - - return ''; -} - -function embeddedPackageRelativePath(p: ResolvedEmbedded): string { - return path.join( - 'embedded', - p.packageName.replace(/^@/, '').replace(/\//, '-'), - ); -} diff --git a/src/commands/export-dynamic-plugin/command.ts b/src/commands/export-dynamic-plugin/command.ts index 74a9103..708d275 100644 --- a/src/commands/export-dynamic-plugin/command.ts +++ b/src/commands/export-dynamic-plugin/command.ts @@ -15,6 +15,7 @@ */ import { PackageRoles } from '@backstage/cli-node'; +import { bundleCommand } from '@backstage/cli-module-build/dist/commands/package/bundle/command.cjs.js'; import chalk from 'chalk'; import { OptionValues } from 'commander'; @@ -26,10 +27,34 @@ import path from 'path'; import { paths } from '../../lib/paths'; import { getConfigSchema } from '../../lib/schema/collect'; import { Task } from '../../lib/tasks'; -import { backend } from './backend'; import { applyDevOptions } from './dev'; import { frontend } from './frontend'; +const DEPRECATED_FLAGS: Record = { + embedPackage: + '--embed-package is deprecated and ignored. The upstream bundle command embeds all dependencies automatically.', + sharedPackage: + '--shared-package is deprecated and ignored. The upstream bundle command produces fully self-contained bundles.', + allowNativePackage: + '--allow-native-package is deprecated and ignored.', + suppressNativePackage: + '--suppress-native-package is deprecated and ignored.', + ignoreVersionCheck: + '--ignore-version-check is deprecated and ignored.', + minify: '--minify is deprecated and ignored.', + trackDynamicManifestAndLockFile: + '--track-dynamic-manifest-and-lock-file is deprecated and ignored.', +}; + +function warnDeprecatedFlags(opts: OptionValues): void { + for (const [flag, message] of Object.entries(DEPRECATED_FLAGS)) { + const value = opts[flag]; + if (value !== undefined && value !== false) { + Task.log(chalk.yellow(message)); + } + } +} + export async function command(opts: OptionValues): Promise { const rawPkg = await fs.readJson(paths.resolveTarget('package.json')); const role = PackageRoles.getRoleFromPackage(rawPkg); @@ -39,16 +64,36 @@ export async function command(opts: OptionValues): Promise { let targetPath: string; const roleInfo = PackageRoles.getRoleInfo(role); - let configSchemaPaths: string[]; if (role === 'backend-plugin' || role === 'backend-plugin-module') { - targetPath = await backend(opts); - configSchemaPaths = [ - path.join(targetPath, 'dist/configSchema.json'), - path.join(targetPath, 'dist/.config-schema.json'), - ]; + warnDeprecatedFlags(opts); + + await bundleCommand({ + clean: Boolean(opts.clean), + build: opts.build !== false, + install: opts.install !== false, + verbose: Boolean(opts.verbose), + outputName: 'dist-dynamic', + outputDestination: opts.outputDestination, + prePackedDir: opts.prePackedDir, + }); + + targetPath = path.join(paths.targetDir, 'dist-dynamic'); + + // Rename package to ${name}-dynamic + const targetPkgPath = path.join(targetPath, 'package.json'); + const targetPkg = await fs.readJson(targetPkgPath); + targetPkg.name = `${rawPkg.name}-dynamic`; + await fs.writeJson(targetPkgPath, targetPkg, { spaces: 2 }); + + // Copy config schema to the path RHDH's schemaLocator expects + const upstreamSchema = path.join(targetPath, 'dist', '.config-schema.json'); + const rhdhSchema = path.join(targetPath, 'dist', 'configSchema.json'); + if (await fs.pathExists(upstreamSchema)) { + await fs.copy(upstreamSchema, rhdhSchema); + } } else if (role === 'frontend-plugin' || role === 'frontend-plugin-module') { targetPath = await frontend(roleInfo, opts); - configSchemaPaths = []; + const configSchemaPaths: string[] = []; if (fs.existsSync(path.join(targetPath, 'dist-scalprum'))) { configSchemaPaths.push( path.join(targetPath, 'dist-scalprum/configSchema.json'), @@ -57,24 +102,30 @@ export async function command(opts: OptionValues): Promise { if (fs.existsSync(path.join(targetPath, 'dist'))) { configSchemaPaths.push(path.join(targetPath, 'dist/.config-schema.json')); } + + if (configSchemaPaths.length > 0) { + Task.log( + `Saving self-contained config schema in ${chalk.cyan(configSchemaPaths.join(' and '))}`, + ); + + const configSchema = await getConfigSchema(rawPkg.name); + for (const configSchemaPath of configSchemaPaths) { + await fs.writeJson( + paths.resolveTarget(configSchemaPath), + configSchema, + { + encoding: 'utf8', + spaces: 2, + }, + ); + } + } } else { throw new Error( - 'Only packages with the "backend-plugin", "backend-plugin-module" or "frontend-plugin" roles can be exported as dynamic backend plugins', + 'Only packages with the "backend-plugin", "backend-plugin-module", "frontend-plugin" or "frontend-plugin-module" roles can be exported as dynamic plugins', ); } - Task.log( - `Saving self-contained config schema in ${chalk.cyan(configSchemaPaths.join(' and '))}`, - ); - - const configSchema = await getConfigSchema(rawPkg.name); - for (const configSchemaPath of configSchemaPaths) { - await fs.writeJson(paths.resolveTarget(configSchemaPath), configSchema, { - encoding: 'utf8', - spaces: 2, - }); - } - await checkBackstageSupportedVersions(targetPath); await applyDevOptions(opts, rawPkg.name, roleInfo, targetPath); diff --git a/src/commands/export-dynamic-plugin/frontend.ts b/src/commands/export-dynamic-plugin/frontend.ts index ca7b2e9..739c802 100644 --- a/src/commands/export-dynamic-plugin/frontend.ts +++ b/src/commands/export-dynamic-plugin/frontend.ts @@ -28,7 +28,7 @@ import { buildScalprumPlugin } from '../../lib/builder/buildScalprumPlugin'; import { productionPack } from '../../lib/packager/productionPack'; import { paths } from '../../lib/paths'; import { Task } from '../../lib/tasks'; -import { customizeForDynamicUse } from './backend'; +import { customizeForDynamicUse } from './utils'; function isTruthyCiEnv(value: string | undefined): boolean { if (value === undefined) { diff --git a/src/commands/export-dynamic-plugin/utils.ts b/src/commands/export-dynamic-plugin/utils.ts new file mode 100644 index 0000000..62c11e9 --- /dev/null +++ b/src/commands/export-dynamic-plugin/utils.ts @@ -0,0 +1,344 @@ +/* + * Copyright 2024 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 } from '@backstage/cli-node'; + +import { Packages } from '@manypkg/get-packages'; +import chalk from 'chalk'; +import * as fs from 'fs-extra'; +import * as semver from 'semver'; + +import { + isBackstageVersionSpec, + resolveBackstageVersion, +} from '../../lib/backstageVersion'; +import { Task } from '../../lib/tasks'; + +export type ResolvedEmbedded = { + packageName: string; + version: string; + dir: string; + parentPackageName: string; + alreadyPacked: boolean; +}; + +type SharedPackagesRules = { + include: (string | RegExp)[]; + exclude: (string | RegExp)[]; +}; + +function checkWorkspacePackageVersion( + requiredVersionSpec: string, + pkg: { version: string; dir: string }, +): boolean { + const versionDetail = requiredVersionSpec.replace(/^workspace:/, ''); + + return ( + pkg.dir === versionDetail || + versionDetail === '*' || + versionDetail === '~' || + versionDetail === '^' || + semver.satisfies(pkg.version, versionDetail) + ); +} + +/** + * Resolves workspace: and backstage: protocol version specs to concrete versions. + * + * - workspace:^ / workspace:~ / workspace:* => lookup in embedded, then monoRepoPackages + * - backstage:^ => delegate to resolveBackstageVersion() + * - anything else => undefined (no transformation needed) + * + * Preserves the range prefix: workspace:^ => ^1.5.0, workspace:~ => ~1.5.0, + * workspace:* => 1.5.0, backstage:^ => ^1.5.0. + */ +async function resolveProtocolVersion( + dep: string, + versionSpec: string, + embedded: ResolvedEmbedded[], + monoRepoPackages: Packages | undefined, + contextPkgName: string, +): Promise<{ resolved: string; exact: string } | undefined> { + if (versionSpec.startsWith('workspace:')) { + let resolvedVersion: string | undefined; + const rangeSpecifier = versionSpec.replace(/^workspace:/, ''); + const embeddedDep = embedded.find( + e => + e.packageName === dep && checkWorkspacePackageVersion(versionSpec, e), + ); + if (embeddedDep) { + resolvedVersion = embeddedDep.version; + } else if (monoRepoPackages) { + const relatedMonoRepoPackages = monoRepoPackages.packages.filter( + p => p.packageJson.name === dep, + ); + if (relatedMonoRepoPackages.length > 1) { + throw new Error( + `Two packages named ${chalk.cyan( + dep, + )} exist in the monorepo structure: this is not supported.`, + ); + } + if ( + relatedMonoRepoPackages.length === 1 && + checkWorkspacePackageVersion(versionSpec, { + dir: relatedMonoRepoPackages[0].dir, + version: relatedMonoRepoPackages[0].packageJson.version, + }) + ) { + resolvedVersion = relatedMonoRepoPackages[0].packageJson.version; + } + } + + if (!resolvedVersion) { + throw new Error( + `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( + contextPkgName, + )} doesn't exist in the monorepo structure: maybe you should embed it ?`, + ); + } + + const exact = resolvedVersion; + if (rangeSpecifier === '^' || rangeSpecifier === '~') { + resolvedVersion = rangeSpecifier + resolvedVersion; + } + return { resolved: resolvedVersion, exact }; + } + + if (isBackstageVersionSpec(versionSpec)) { + const resolvedVersion = await resolveBackstageVersion(dep, versionSpec); + if (resolvedVersion) { + Task.log( + ` resolving ${chalk.cyan(dep)} from ${chalk.yellow( + versionSpec, + )} to ${chalk.green(resolvedVersion)}`, + ); + const exact = resolvedVersion.replace(/^[\^~]/, ''); + return { resolved: resolvedVersion, exact }; + } + } + + return undefined; +} + +function isPackageShared( + pkgName: string, + rules: SharedPackagesRules | undefined, +) { + function test(str: string, expr: string | RegExp): boolean { + if (typeof expr === 'string') { + return str === expr; + } + return expr.test(str); + } + + if ((rules?.exclude || []).some(dontMove => test(pkgName, dontMove))) { + return false; + } + + if ((rules?.include || []).some(move => test(pkgName, move))) { + return true; + } + + return false; +} + +export function customizeForDynamicUse(options: { + embedded: ResolvedEmbedded[]; + isYarnV1: boolean; + monoRepoPackages?: Packages; + sharedPackages?: SharedPackagesRules; + overridding?: + | (Partial & { + bundleDependencies?: boolean; + }) + ; + additionalOverrides?: { [key: string]: any }; + additionalResolutions?: { [key: string]: any }; + after?: ((pkg: BackstagePackageJson) => void); +}): (dynamicPkgPath: string) => Promise { + return async (dynamicPkgPath: string): Promise => { + const dynamicPkgContent = await fs.readFile(dynamicPkgPath, 'utf8'); + const pkgToCustomize = JSON.parse( + dynamicPkgContent, + ) as BackstagePackageJson; + + for (const field in options.overridding || {}) { + if (!Object.prototype.hasOwnProperty.call(options.overridding, field)) { + continue; + } + (pkgToCustomize as any)[field] = (options.overridding as any)[field]; + } + + pkgToCustomize.files = pkgToCustomize.files?.filter( + f => !f.startsWith('dist-dynamic/'), + ); + + // Collect exact versions for pinning in resolutions + const pinnedResolutions: Record = {}; + const embeddedNames = new Set(options.embedded.map(e => e.packageName)); + + // Resolve workspace: and backstage: in dependencies + if (pkgToCustomize.dependencies) { + for (const dep in pkgToCustomize.dependencies) { + if ( + !Object.prototype.hasOwnProperty.call( + pkgToCustomize.dependencies, + dep, + ) + ) { + continue; + } + + const dependencyVersionSpec = pkgToCustomize.dependencies[dep]; + const result = await resolveProtocolVersion( + dep, + dependencyVersionSpec, + options.embedded, + options.monoRepoPackages, + pkgToCustomize.name, + ); + if (result) { + pkgToCustomize.dependencies[dep] = result.resolved; + if (!embeddedNames.has(dep)) { + pinnedResolutions[dep] = result.exact; + } + } + + if (isPackageShared(dep, options.sharedPackages)) { + Task.log(` moving ${chalk.cyan(dep)} to peerDependencies`); + + pkgToCustomize.peerDependencies ||= {}; + pkgToCustomize.peerDependencies[dep] = + pkgToCustomize.dependencies[dep]; + delete pkgToCustomize.dependencies[dep]; + + continue; + } + + // If yarn v1, then detect if the current dep is an embedded one, + // and if it is the case replace the version by the file protocol + // (like what we do for the resolutions). + if (options.isYarnV1) { + const embeddedDep = options.embedded.find( + e => + e.packageName === dep && + checkWorkspacePackageVersion(dependencyVersionSpec, e), + ); + if (embeddedDep) { + pkgToCustomize.dependencies[dep] = + `file:./${embeddedPackageRelativePath(embeddedDep)}`; + } + } + } + } + + // Resolve workspace: and backstage: in pre-existing peerDependencies + if (pkgToCustomize.peerDependencies) { + for (const dep in pkgToCustomize.peerDependencies) { + if ( + !Object.prototype.hasOwnProperty.call( + pkgToCustomize.peerDependencies, + dep, + ) + ) + continue; + const result = await resolveProtocolVersion( + dep, + pkgToCustomize.peerDependencies[dep], + options.embedded, + options.monoRepoPackages, + pkgToCustomize.name, + ); + if (result) { + pkgToCustomize.peerDependencies[dep] = result.resolved; + if (!embeddedNames.has(dep)) { + pinnedResolutions[dep] = result.exact; + } + } + } + } + + // Pin transitive workspace:/backstage: deps reachable from direct deps. + // Without pinning, yarn resolves these from npm (workspace: lockfile entries + // cannot seed npm: entries in dist-dynamic), causing version drift. + if (options.monoRepoPackages) { + const wsPackagesByName = new Map( + options.monoRepoPackages.packages.map(p => [p.packageJson.name, p]), + ); + const queue = Object.keys(pinnedResolutions); + const visited = new Set(queue); + + while (queue.length > 0) { + const pkgName = queue.pop()!; + const wsPkg = wsPackagesByName.get(pkgName); + if (!wsPkg) continue; + + const allDeps: Record = { + ...wsPkg.packageJson.dependencies, + ...wsPkg.packageJson.peerDependencies, + }; + for (const [depName, depVersion] of Object.entries(allDeps)) { + if (visited.has(depName)) continue; + visited.add(depName); + + const isProtocol = + depVersion.startsWith('workspace:') || + depVersion.startsWith('backstage:'); + if (!isProtocol) continue; + if (embeddedNames.has(depName)) continue; + + const depPkg = wsPackagesByName.get(depName); + if (depPkg) { + pinnedResolutions[depName] = depPkg.packageJson.version; + queue.push(depName); + } + } + } + } + + pkgToCustomize.devDependencies = {}; + + const overrides = (pkgToCustomize as any).overrides || {}; + (pkgToCustomize as any).overrides = { + '@aws-sdk/util-utf8-browser': { + '@smithy/util-utf8': '^2.0.0', + }, + ...overrides, + ...(options.additionalOverrides || {}), + }; + const resolutions = (pkgToCustomize as any).resolutions || {}; + (pkgToCustomize as any).resolutions = { + '@aws-sdk/util-utf8-browser': 'npm:@smithy/util-utf8@~2', + ...pinnedResolutions, + ...resolutions, + ...(options.additionalResolutions || {}), + }; + + if (options.after) { + options.after(pkgToCustomize); + } + + await fs.writeJson(dynamicPkgPath, pkgToCustomize, { + encoding: 'utf8', + spaces: 2, + }); + }; +} + +function embeddedPackageRelativePath(p: ResolvedEmbedded): string { + return `embedded/${p.packageName.replace(/^@/, '').replace(/\//, '-')}`; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 978160d..ac28b0e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -30,27 +30,6 @@ export function registerPluginCommand(program: Command) { .description( 'Build and export a plugin package to be loaded as a dynamic plugin. The repackaged dynamic plugin is exported inside a ./dist-dynamic sub-folder.', ) - .option('--minify', 'Minify the generated code (backend plugin only).') - .option( - '--embed-package [package-name...]', - 'Optional list of packages that should be embedded inside the generated code of a backend dynamic plugin, removed from the plugin dependencies, while their direct dependencies will be hoisted to the dynamic plugin dependencies (backend plugin only).', - ) - .option( - '--shared-package [package-name...]', - 'Optional list of packages that should be considered shared by all dynamic plugins, and will be moved to peer dependencies of the dynamic plugin. The `@backstage` packages are by default considered shared dependencies.', - ) - .option( - '--allow-native-package [package-name...]', - 'Optional list of native packages names that can be included in the exported plugin', - ) - .option( - '--suppress-native-package [package-name...]', - 'Optional list of native package names to be excluded from the exported plugin', - ) - .option( - '--ignore-version-check [packageName...]', - 'Optional list of package names to ignore when doing semver dependency checks', - ) .option( '--no-install', 'Do not run `yarn install` to fill the dynamic plugin `node_modules` folder (backend plugin only).', @@ -63,6 +42,18 @@ export function registerPluginCommand(program: Command) { '--clean', 'Remove the dynamic plugin output before exporting again.', ) + .option( + '--verbose', + 'Stream detailed output from internal build, pack, and install steps to the console (backend plugin only).', + ) + .option( + '--output-destination ', + 'Directory in which the bundle subdirectory is created. Defaults to the current package directory (backend plugin only).', + ) + .option( + '--pre-packed-dir ', + 'Path to a pre-built dist workspace (from backstage-cli build-workspace --alwaysPack). Skips local dependency packing and uses pre-packed packages directly (backend plugin only).', + ) .option( '--dev', 'Allow testing/debugging a dynamic plugin locally. This creates a link from the dynamic plugin content to the plugin package `src` folder, to enable the use of source maps (backend plugin only). This also installs the dynamic plugin content (symlink) into the dynamic plugins root folder configured in the app config (or copies the plugin content to the location explicitely provided by the `--dynamic-plugins-root` argument).', @@ -75,9 +66,30 @@ export function registerPluginCommand(program: Command) { '--scalprum-config ', 'Allows retrieving scalprum configuration from an external JSON file, instead of using a `scalprum` field of the `package.json`. Frontend plugins only.', ) + .option('--minify', '[Deprecated] No longer supported. Ignored.') + .option( + '--embed-package [package-name...]', + '[Deprecated] No longer supported. The upstream bundle command embeds all dependencies automatically. Ignored.', + ) + .option( + '--shared-package [package-name...]', + '[Deprecated] No longer supported. The upstream bundle command produces fully self-contained bundles. Ignored.', + ) + .option( + '--allow-native-package [package-name...]', + '[Deprecated] No longer supported. Ignored.', + ) + .option( + '--suppress-native-package [package-name...]', + '[Deprecated] No longer supported. Ignored.', + ) + .option( + '--ignore-version-check [packageName...]', + '[Deprecated] No longer supported. Ignored.', + ) .option( '--track-dynamic-manifest-and-lock-file', - 'Adds the `package.json` and `yarn.lock` files, generated in the `dist-dynamic` folder of backend plugins, to source control. By default the whole `dist-dynamic` folder id git-ignored.', + '[Deprecated] No longer supported. Ignored.', false, ) .option( diff --git a/src/generated/backstage-cli-types.d.ts b/src/generated/backstage-cli-types.d.ts index 7100ae2..bba01cb 100644 --- a/src/generated/backstage-cli-types.d.ts +++ b/src/generated/backstage-cli-types.d.ts @@ -37,3 +37,17 @@ declare module '@backstage/cli-module-build/dist/lib/buildBackend.cjs.js' { ): Promise; export {}; } + +declare module '@backstage/cli-module-build/dist/commands/package/bundle/command.cjs.js' { + interface BundleOptions { + build: boolean; + install: boolean; + clean: boolean; + verbose: boolean; + outputDestination?: string; + outputName?: string; + prePackedDir?: string; + } + export declare function bundleCommand(opts: BundleOptions): Promise; + export {}; +} From 4ee85d0a5d01a7e827edb45afa676b9592c6250e Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Tue, 30 Jun 2026 15:42:19 -0400 Subject: [PATCH 2/5] chore: prettier fix Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/export-dynamic-plugin/command.ts | 9 +++------ src/commands/export-dynamic-plugin/utils.ts | 10 ++++------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/commands/export-dynamic-plugin/command.ts b/src/commands/export-dynamic-plugin/command.ts index 708d275..30b522d 100644 --- a/src/commands/export-dynamic-plugin/command.ts +++ b/src/commands/export-dynamic-plugin/command.ts @@ -35,12 +35,9 @@ const DEPRECATED_FLAGS: Record = { '--embed-package is deprecated and ignored. The upstream bundle command embeds all dependencies automatically.', sharedPackage: '--shared-package is deprecated and ignored. The upstream bundle command produces fully self-contained bundles.', - allowNativePackage: - '--allow-native-package is deprecated and ignored.', - suppressNativePackage: - '--suppress-native-package is deprecated and ignored.', - ignoreVersionCheck: - '--ignore-version-check is deprecated and ignored.', + allowNativePackage: '--allow-native-package is deprecated and ignored.', + suppressNativePackage: '--suppress-native-package is deprecated and ignored.', + ignoreVersionCheck: '--ignore-version-check is deprecated and ignored.', minify: '--minify is deprecated and ignored.', trackDynamicManifestAndLockFile: '--track-dynamic-manifest-and-lock-file is deprecated and ignored.', diff --git a/src/commands/export-dynamic-plugin/utils.ts b/src/commands/export-dynamic-plugin/utils.ts index 62c11e9..87272fb 100644 --- a/src/commands/export-dynamic-plugin/utils.ts +++ b/src/commands/export-dynamic-plugin/utils.ts @@ -161,14 +161,12 @@ export function customizeForDynamicUse(options: { isYarnV1: boolean; monoRepoPackages?: Packages; sharedPackages?: SharedPackagesRules; - overridding?: - | (Partial & { - bundleDependencies?: boolean; - }) - ; + overridding?: Partial & { + bundleDependencies?: boolean; + }; additionalOverrides?: { [key: string]: any }; additionalResolutions?: { [key: string]: any }; - after?: ((pkg: BackstagePackageJson) => void); + after?: (pkg: BackstagePackageJson) => void; }): (dynamicPkgPath: string) => Promise { return async (dynamicPkgPath: string): Promise => { const dynamicPkgContent = await fs.readFile(dynamicPkgPath, 'utf8'); From 6b4f85b8f34659f9442ebdf46125d7e8fa3afcd6 Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Thu, 2 Jul 2026 09:00:49 -0400 Subject: [PATCH 3/5] chore: switch to using node tar to fix hard link issue Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- e2e-tests/support/plugin-export-build.ts | 9 +-- package.json | 2 +- .../package-dynamic-plugins/command.ts | 57 ++++++++++--------- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/e2e-tests/support/plugin-export-build.ts b/e2e-tests/support/plugin-export-build.ts index e1de336..fe6a03e 100644 --- a/e2e-tests/support/plugin-export-build.ts +++ b/e2e-tests/support/plugin-export-build.ts @@ -108,10 +108,11 @@ export async function topLevelEntriesAfterNpmPackStaging( } const extractInto = path.join(work, 'extracted'); fs.mkdirSync(extractInto, { recursive: true }); - await runCommand( - `tar -xzf "${path.join(packDest, tgzs[0])}" -C "${extractInto}" --strip-components=1`, - { cwd: distDynamicDir }, - ); + await tar.x({ + file: path.join(packDest, tgzs[0]), + cwd: extractInto, + strip: 1, + }); return fs.readdirSync(extractInto).sort(); } finally { await fs.remove(work).catch(() => undefined); diff --git a/package.json b/package.json index a0535c3..459195d 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "semver": "^7.5.4", "style-loader": "^3.3.1", "swc-loader": "^0.2.3", + "tar": "^7.5.7", "typescript": "5.4.5", "typescript-json-schema": "^0.64.0", "webpack": "~5.105.0", @@ -121,7 +122,6 @@ "mock-fs": "5.2.0", "nodemon": "3.1.3", "prettier": "3.3.3", - "tar": "^7.5.7", "ts-jest": "^29.3.4", "ts-node": "10.9.2", "type-fest": "4.20.1" diff --git a/src/commands/package-dynamic-plugins/command.ts b/src/commands/package-dynamic-plugins/command.ts index e39b580..d6020e2 100644 --- a/src/commands/package-dynamic-plugins/command.ts +++ b/src/commands/package-dynamic-plugins/command.ts @@ -7,6 +7,7 @@ import chalk from 'chalk'; import { OptionValues } from 'commander'; import fs from 'fs-extra'; import { PackageJson } from 'type-fest'; +import * as tar from 'tar'; import YAML from 'yaml'; import os from 'node:os'; @@ -322,20 +323,16 @@ type StageDistDynamicViaNpmPackOptions = { /** * Stages `dist-dynamic` like a published tarball: `npm pack` to a scratch - * directory, then `tar -xzf … --strip-components=1` into the target. Omits + * directory, then extract with node-tar (strip: 1) into the target. Omits * `node_modules/.bin` and other paths npm does not ship (RHDHBUGS-1968). * - * Sends npm/tar stdout and stderr to a temp log file (path is printed only on - * failure), not to the terminal, by wiring those streams to fd(s) opened on - * that file in the `spawn` options. + * Uses node-tar for extraction instead of system tar to correctly handle + * forward-referencing hard links in bundled dependency tarballs. * - * Uses `spawn` with `shell: false`; pack paths are inlined with `bashSingleQuoted`. - * Does not pass a custom `env` (child inherits PATH so user-managed toolchains - * resolve — see adjacent NOSONAR). `Task.forCommand` lacks custom env support; `run()` - * uses `shell: true` on `spawn` and can replay npm output to the CLI. + * Sends npm stdout/stderr to a temp log file (path is printed only on + * failure), not to the terminal. * - * Requires `bash`, `npm` 7+ for `--pack-destination`, and `tar` on `PATH` - * (Linux, macOS, Git Bash, or WSL on Windows). + * Requires `bash` and `npm` 7+ for `--pack-destination` on `PATH`. */ async function stageDistDynamicViaNpmPack( options: StageDistDynamicViaNpmPackOptions, @@ -353,29 +350,42 @@ async function stageDistDynamicViaNpmPack( const logFd = openSync(packLogPath, 'w'); try { - // Controlled bash -lc script (paths from bashSingleQuoted); not user-defined. - // child inherits PATH so npm/bash/tar resolve (nvm, fnm, Homebrew). const child = spawn( 'bash', // NOSONAR typescript:S4036 - ['-lc', npmPackExtractScript(packdir, targetDirectory)], + ['-lc', npmPackScript(packdir)], { cwd: distDynamicDirectory, stdio: ['ignore', logFd, logFd], shell: false, }, ); - await waitForExit(child, 'npm pack / tar'); + await waitForExit(child, 'npm pack'); } finally { closeSync(logFd); } + const tgzFiles = fs + .readdirSync(packdir) + .filter(f => f.endsWith('.tgz')); + if (tgzFiles.length !== 1) { + throw new Error( + `expected exactly one .tgz in ${packdir}, got: ${tgzFiles.join(', ')}`, + ); + } + + await tar.x({ + file: path.join(packdir, tgzFiles[0]), + cwd: targetDirectory, + strip: 1, + }); + await fs.remove(packLogPath).catch(() => undefined); } catch (err) { if (await fs.pathExists(packLogPath)) { const logContents = await fs .readFile(packLogPath, 'utf8') .catch(() => ''); - const logHeader = chalk.yellow(`npm pack / tar output (${packLogPath}):`); + const logHeader = chalk.yellow(`npm pack output (${packLogPath}):`); process.stderr.write(`${logHeader}\n\n${logContents}\n`); await fs.remove(packLogPath).catch(() => undefined); } @@ -397,23 +407,14 @@ function bashSingleQuoted(value: string): string { } /** - * `npm pack` into `packdir`, then extract into `extractToDir` with the same - * layout as today’s staging tree (`package/` stripped). Fails if `packdir` does - * not contain exactly one `.tgz` after pack. Paths are bash-quoted in the script - * instead of passed via extra `env` entries. + * Bash script that runs `npm pack` into the given directory. + * Extraction is handled separately by node-tar which correctly resolves + * forward-referencing hard links in bundled dependency tarballs. */ -function npmPackExtractScript(packdir: string, extractToDir: string): string { +function npmPackScript(packdir: string): string { const qPack = bashSingleQuoted(packdir); - const qExtract = bashSingleQuoted(extractToDir); return `set -euo pipefail npm pack --pack-destination ${qPack} --foreground-scripts=false -shopt -s nullglob -tgzs=(${qPack}/*.tgz) -if (( \${#tgzs[@]} != 1 )); then - echo "expected exactly one .tgz in ${packdir}, got: \${tgzs[*]}" >&2 - exit 1 -fi -tar -xzf "\${tgzs[0]}" -C ${qExtract} --strip-components=1 `; } From afe75d2f892a8978d7cf6b36368c8f22418a519f Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Thu, 2 Jul 2026 09:22:39 -0400 Subject: [PATCH 4/5] chore: yarn prettier:fix Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/package-dynamic-plugins/command.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/package-dynamic-plugins/command.ts b/src/commands/package-dynamic-plugins/command.ts index d6020e2..b284c46 100644 --- a/src/commands/package-dynamic-plugins/command.ts +++ b/src/commands/package-dynamic-plugins/command.ts @@ -364,9 +364,7 @@ async function stageDistDynamicViaNpmPack( closeSync(logFd); } - const tgzFiles = fs - .readdirSync(packdir) - .filter(f => f.endsWith('.tgz')); + const tgzFiles = fs.readdirSync(packdir).filter(f => f.endsWith('.tgz')); if (tgzFiles.length !== 1) { throw new Error( `expected exactly one .tgz in ${packdir}, got: ${tgzFiles.join(', ')}`, From 1bc5f36a9a38d88cd0d8609e46dfff1f9b15b2a6 Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Thu, 2 Jul 2026 16:03:49 -0400 Subject: [PATCH 5/5] chore: disable yarn hardening since it conflicts with the YARN_ENABLE_NETWORK=0 used in the bundle command Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 459195d..7c91920 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "prettier:check": "prettier --ignore-unknown --check .", "prettier:fix": "prettier --ignore-unknown --write .", "test": "backstage-cli package test --passWithNoTests --coverage", - "test:e2e": "jest --config e2e-tests/jest.config.js", + "test:e2e": "YARN_ENABLE_HARDENED_MODE=0 jest --config e2e-tests/jest.config.js", "clean": "backstage-cli package clean", "generate-types": "node scripts/generate-backstage-types.js" },