diff --git a/.gitignore b/.gitignore index 11e3986..9d1fd7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # macOS .DS_Store +# IDEs +.vscode/ # Logs logs *.log 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 17c0db5..f101cf1 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", "backstage:bump": "backstage-cli versions:bump --release 1.52.0 --skip-install && sed -i '/@backstage\\//s/\"\\^/\"~/g' package.json && sed -i '/@backstage\\/cli/s/\"~/\"/g' package.json && yarn install && yarn dedupe", "clean": "backstage-cli package clean", "generate-types": "node scripts/generate-backstage-types.js" @@ -84,6 +84,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", @@ -123,7 +124,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/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/check-heavy-deps.test.ts b/src/commands/export-dynamic-plugin/check-heavy-deps.test.ts new file mode 100644 index 0000000..cf5cb5b --- /dev/null +++ b/src/commands/export-dynamic-plugin/check-heavy-deps.test.ts @@ -0,0 +1,187 @@ +/* + * 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 mockFs from 'mock-fs'; + +import { Task } from '../../lib/tasks'; +import { checkHeavyDependencies } from './check-heavy-deps'; + +describe('checkHeavyDependencies', () => { + const targetPath = '/tmp/dist-dynamic'; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(Task, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + mockFs.restore(); + logSpy.mockRestore(); + }); + + describe('backend', () => { + it('does not warn when no heavy deps are present', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-plugin-api': '1.0.0', + '@backstage/plugin-auth-node': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns for each heavy dependency in production dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-defaults': '1.0.0', + '@backstage/backend-app-api': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).toHaveBeenCalledTimes(2); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-defaults', + ); + expect(logSpy.mock.calls[1][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-app-api', + ); + expect(logSpy.mock.calls[0][0]).toContain( + 'Should not be used in backend plugins', + ); + expect(logSpy.mock.calls[0][0]).not.toMatch(/~\d+/); + }); + + it('throws in strict mode after logging all violations', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-test-utils': '1.0.0', + }, + }), + }, + }); + + await expect( + checkHeavyDependencies(targetPath, true, 'backend'), + ).rejects.toThrow( + 'Found 1 disallowed dependency in production dependencies', + ); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-test-utils', + ); + }); + + it('only checks production dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-plugin-api': '1.0.0', + }, + devDependencies: { + '@backstage/backend-test-utils': '1.0.0', + }, + peerDependencies: { + '@backstage/backend-defaults': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('frontend', () => { + it('does not warn when no heavy deps are present', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/frontend-plugin-api': '1.0.0', + '@backstage/core-components': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns for app-level and dev/test frontend dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/core-app-api': '1.0.0', + '@backstage/frontend-defaults': '1.0.0', + '@backstage/frontend-test-utils': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).toHaveBeenCalledTimes(3); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/core-app-api', + ); + expect(logSpy.mock.calls[1][0]).toContain( + 'WARNING: Found heavy dependency @backstage/frontend-defaults', + ); + expect(logSpy.mock.calls[2][0]).toContain( + 'WARNING: Found heavy dependency @backstage/frontend-test-utils', + ); + }); + + it('does not flag allowed plugin API packages', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/core-plugin-api': '1.0.0', + '@backstage/frontend-plugin-api': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/export-dynamic-plugin/check-heavy-deps.ts b/src/commands/export-dynamic-plugin/check-heavy-deps.ts new file mode 100644 index 0000000..384c5e4 --- /dev/null +++ b/src/commands/export-dynamic-plugin/check-heavy-deps.ts @@ -0,0 +1,92 @@ +/* + * 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 chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; + +import { Task } from '../../lib/tasks'; + +export const HEAVY_BACKEND_DEPS: Record = { + '@backstage/backend-defaults': + 'Should not be used in backend plugins. Use @backstage/backend-plugin-api instead.', + '@backstage/backend-app-api': + 'Should not be used in backend plugins. Use @backstage/backend-plugin-api instead.', + '@backstage/backend-test-utils': + 'Should not be used in production dependencies. Move to devDependencies.', + '@backstage/backend-dynamic-feature-service': + 'Should not be used in backend plugins. Use the -node variant instead.', +}; + +export const HEAVY_FRONTEND_DEPS: Record = { + '@backstage/core-app-api': + 'Should not be used in frontend plugins. Use @backstage/frontend-plugin-api or @backstage/core-plugin-api instead.', + '@backstage/frontend-app-api': + 'Should not be used in frontend plugins. Use @backstage/frontend-plugin-api instead.', + '@backstage/frontend-defaults': + 'App-level wiring only. Use in app packages or move to devDependencies.', + '@backstage/app-defaults': + 'App-level wiring only. Use in app packages or move to devDependencies.', + '@backstage/dev-utils': 'Dev server helper only. Move to devDependencies.', + '@backstage/frontend-dev-utils': + 'Dev server helper only. Move to devDependencies.', + '@backstage/frontend-test-utils': + 'Test utilities only. Move to devDependencies.', + '@backstage/test-utils': 'Test utilities only. Move to devDependencies.', + '@backstage/frontend-dynamic-feature-loader': + 'App-level dynamic feature loading. Should not be a plugin production dependency.', +}; + +export type HeavyDepKind = 'backend' | 'frontend'; + +const HEAVY_DEPS_BY_KIND: Record> = { + backend: HEAVY_BACKEND_DEPS, + frontend: HEAVY_FRONTEND_DEPS, +}; + +export async function checkHeavyDependencies( + targetPath: string, + strict: boolean, + kind: HeavyDepKind, +): Promise { + const blocklist = HEAVY_DEPS_BY_KIND[kind]; + const packageJsonPath = path.join(targetPath, 'package.json'); + const targetPackage = await fs.readJson(packageJsonPath); + const dependencies: Record = targetPackage.dependencies ?? {}; + + const violations = Object.keys(dependencies).filter(dep => dep in blocklist); + + if (violations.length === 0) { + return; + } + + for (const dep of violations) { + Task.log( + chalk.yellow( + [ + `WARNING: Found heavy dependency ${chalk.cyan(dep)} in production dependencies.`, + ` ${blocklist[dep]}`, + ].join('\n'), + ), + ); + } + + if (strict) { + throw new Error( + `Found ${violations.length} disallowed ${violations.length === 1 ? 'dependency' : 'dependencies'} in production dependencies. Remove ${violations.length === 1 ? 'it' : 'them'} or omit --strict-deps to export with warnings only.`, + ); + } +} diff --git a/src/commands/export-dynamic-plugin/command.ts b/src/commands/export-dynamic-plugin/command.ts index 74a9103..3bb2c20 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,32 @@ 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 { checkHeavyDependencies, HeavyDepKind } from './check-heavy-deps'; 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 +62,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 +100,40 @@ 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 heavyDepKind: HeavyDepKind = + role === 'backend-plugin' || role === 'backend-plugin-module' + ? 'backend' + : 'frontend'; + await checkHeavyDependencies( + targetPath, + Boolean(opts.strictDeps), + heavyDepKind, ); - 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 a1aa95b..c8dd9c6 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'; import { detectBackstageFeatures } from './features'; function isTruthyCiEnv(value: string | undefined): boolean { diff --git a/src/commands/export-dynamic-plugin/utils.ts b/src/commands/export-dynamic-plugin/utils.ts new file mode 100644 index 0000000..87272fb --- /dev/null +++ b/src/commands/export-dynamic-plugin/utils.ts @@ -0,0 +1,342 @@ +/* + * 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..def2f14 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -30,38 +30,34 @@ 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.', + '--no-install', + 'Do not run `yarn install` to fill the dynamic plugin `node_modules` folder (backend plugin only).', ) .option( - '--allow-native-package [package-name...]', - 'Optional list of native packages names that can be included in the exported plugin', + '--no-build', + 'Do not run `yarn build` on the main and embedded packages before exporting (backend plugin only).', ) .option( - '--suppress-native-package [package-name...]', - 'Optional list of native package names to be excluded from the exported plugin', + '--clean', + 'Remove the dynamic plugin output before exporting again.', ) .option( - '--ignore-version-check [packageName...]', - 'Optional list of package names to ignore when doing semver dependency checks', + '--verbose', + 'Stream detailed output from internal build, pack, and install steps to the console (backend plugin only).', ) .option( - '--no-install', - 'Do not run `yarn install` to fill the dynamic plugin `node_modules` folder (backend plugin only).', + '--output-destination ', + 'Directory in which the bundle subdirectory is created. Defaults to the current package directory (backend plugin only).', ) .option( - '--no-build', - 'Do not run `yarn build` on the main and embedded packages before exporting (backend plugin only).', + '--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( - '--clean', - 'Remove the dynamic plugin output before exporting again.', + '--strict-deps', + 'Fail export when production dependencies include disallowed heavy packages. Use in CI to enforce dependency rules.', + false, ) .option( '--dev', @@ -75,9 +71,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/commands/package-dynamic-plugins/command.ts b/src/commands/package-dynamic-plugins/command.ts index e39b580..b284c46 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,40 @@ 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 +405,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 `; } diff --git a/src/generated/backstage-cli-types.d.ts b/src/generated/backstage-cli-types.d.ts index 928c4d3..1df38db 100644 --- a/src/generated/backstage-cli-types.d.ts +++ b/src/generated/backstage-cli-types.d.ts @@ -37,6 +37,19 @@ declare module '@backstage/cli-module-build/dist/lib/buildBackend.cjs.js' { 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 {}; +} declare module '@backstage/cli-module-build/dist/lib/typeDistProject.cjs.js' { import { BackstagePackageFeatureType,