From 920f14e7ed578080f5ab95c05b5c6ee319e2cf39 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 5 Dec 2025 10:31:02 -0500 Subject: [PATCH] fix: yarn.lock for frontend plugins This change updates the CLI's frontend script to create a yarn.lock based on the exported plugin package in a similar fashion to backend plugins. This makes the exported plugin package for frontend plugins more consistent with backend plugins and allows another means for security scanners to inspect the plugin's dependencies. This change also moves functions that are shared between the backend and frontend commands into a shared utils file so it's more obvious which functions are common to each command. --- src/commands/export-dynamic-plugin/backend.ts | 293 ++---------------- src/commands/export-dynamic-plugin/command.ts | 4 +- .../export-dynamic-plugin/common-utils.ts | 246 +++++++++++++++ .../export-dynamic-plugin/frontend.ts | 251 +++++++++------ src/commands/export-dynamic-plugin/types.ts | 12 + 5 files changed, 450 insertions(+), 356 deletions(-) create mode 100644 src/commands/export-dynamic-plugin/common-utils.ts create mode 100644 src/commands/export-dynamic-plugin/types.ts diff --git a/src/commands/export-dynamic-plugin/backend.ts b/src/commands/export-dynamic-plugin/backend.ts index 5a0e5f0..fed6320 100644 --- a/src/commands/export-dynamic-plugin/backend.ts +++ b/src/commands/export-dynamic-plugin/backend.ts @@ -23,7 +23,7 @@ import { OptionValues } from 'commander'; import * as fs from 'fs-extra'; import * as semver from 'semver'; -import { execSync } from 'child_process'; +import { execSync } from 'node:child_process'; import { createRequire } from 'node:module'; import * as path from 'path'; @@ -37,6 +37,14 @@ import { gatherNativeModules, isValidPluginModule, } from './backend-utils'; +import { + checkWorkspacePackageVersion, + customizeForDynamicUse, + embeddedPackageRelativePath, + isPackageShared, + locateAndCopyYarnLock, +} from './common-utils'; +import { ResolvedEmbedded, SharedPackagesRules } from './types'; export async function backend(opts: OptionValues): Promise { const targetRelativePath = 'dist-dynamic'; @@ -209,7 +217,7 @@ throw new Error( isYarnV1: yarnVersion.startsWith('1.'), monoRepoPackages, sharedPackages: sharedPackagesRules, - overridding: { + overriding: { private: true, version: `${embedded.version}+embedded`, }, @@ -276,7 +284,7 @@ throw new Error( isYarnV1: yarnVersion.startsWith('1.'), monoRepoPackages, sharedPackages: sharedPackagesRules, - overridding: { + overriding: { name: derivedPackageName, bundleDependencies: true, // We remove scripts, because they do not make sense for this derived package. @@ -317,40 +325,11 @@ throw new Error( 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`, - ), - ); - } + await locateAndCopyYarnLock({ + targetDir: paths.targetDir, + targetRoot: paths.targetRoot, + yarnLock, + }); } if (opts.install) { @@ -471,18 +450,22 @@ throw new Error( } // everything is fine, remove the yarn install log await fs.remove(paths.resolveTarget(targetRelativePath, logFile)); + } else { + 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`, + ), + ); } return target; } -type ResolvedEmbedded = { - packageName: string; - version: string; - dir: string; - parentPackageName: string; - alreadyPacked: boolean; -}; - async function searchEmbedded( pkg: BackstagePackageJson, packagesToEmbed: string[], @@ -638,219 +621,6 @@ async function searchEmbedded( 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) - ); -} - -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/'), - ); - - if (pkgToCustomize.dependencies) { - for (const dep in pkgToCustomize.dependencies) { - if ( - !Object.prototype.hasOwnProperty.call( - pkgToCustomize.dependencies, - dep, - ) - ) { - continue; - } - - const dependencyVersionSpec = pkgToCustomize.dependencies[dep]; - if (dependencyVersionSpec.startsWith('workspace:')) { - let resolvedVersion: string | undefined; - const rangeSpecifier = dependencyVersionSpec.replace( - /^workspace:/, - '', - ); - const embeddedDep = options.embedded.find( - e => - e.packageName === dep && - checkWorkspacePackageVersion(dependencyVersionSpec, e), - ); - if (embeddedDep) { - resolvedVersion = embeddedDep.version; - } else if (options.monoRepoPackages) { - const relatedMonoRepoPackages = - options.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(dependencyVersionSpec, { - dir: relatedMonoRepoPackages[0].dir, - version: relatedMonoRepoPackages[0].packageJson.version, - }) - ) { - resolvedVersion = - rangeSpecifier === '^' || rangeSpecifier === '~' - ? rangeSpecifier + - relatedMonoRepoPackages[0].packageJson.version - : relatedMonoRepoPackages[0].packageJson.version; - } - } - - if (!resolvedVersion) { - throw new Error( - `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( - pkgToCustomize.name, - )} doesn't exist in the monorepo structure: maybe you should embed it ?`, - ); - } - - pkgToCustomize.dependencies[dep] = resolvedVersion; - } - - 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)}`; - } - } - } - } - - // 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 || {}), - }; - 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', - ...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`); @@ -941,10 +711,3 @@ function validatePluginEntryPoints(target: string): string { 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..6732157 100644 --- a/src/commands/export-dynamic-plugin/command.ts +++ b/src/commands/export-dynamic-plugin/command.ts @@ -38,7 +38,6 @@ 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); @@ -47,7 +46,7 @@ export async function command(opts: OptionValues): Promise { path.join(targetPath, 'dist/.config-schema.json'), ]; } else if (role === 'frontend-plugin' || role === 'frontend-plugin-module') { - targetPath = await frontend(roleInfo, opts); + targetPath = await frontend(opts); configSchemaPaths = []; if (fs.existsSync(path.join(targetPath, 'dist-scalprum'))) { configSchemaPaths.push( @@ -77,6 +76,7 @@ export async function command(opts: OptionValues): Promise { await checkBackstageSupportedVersions(targetPath); + const roleInfo = PackageRoles.getRoleInfo(role); await applyDevOptions(opts, rawPkg.name, roleInfo, targetPath); } diff --git a/src/commands/export-dynamic-plugin/common-utils.ts b/src/commands/export-dynamic-plugin/common-utils.ts new file mode 100644 index 0000000..38ab9b0 --- /dev/null +++ b/src/commands/export-dynamic-plugin/common-utils.ts @@ -0,0 +1,246 @@ +import chalk from 'chalk'; +import fs from 'fs-extra'; + +import path from 'node:path'; +import { BackstagePackageJson } from '@backstage/cli-node'; + +import { Packages } from '@manypkg/get-packages'; +import * as semver from 'semver'; +import { ResolvedEmbedded, SharedPackagesRules } from './types'; +import { Task } from '../../lib/tasks'; + +export 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) + ); +} + +export function customizeForDynamicUse(options: { + embedded: ResolvedEmbedded[]; + isYarnV1: boolean; + monoRepoPackages?: Packages; + sharedPackages?: SharedPackagesRules; + overriding?: 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.overriding || {}) { + if (!Object.hasOwn(options.overriding || {}, field)) { + continue; + } + (pkgToCustomize as any)[field] = (options.overriding as any)[field]; + } + + pkgToCustomize.files = pkgToCustomize.files?.filter( + f => !f.startsWith('dist-dynamic/'), + ); + + if (pkgToCustomize.dependencies) { + for (const dep in pkgToCustomize.dependencies) { + if (!Object.hasOwn(pkgToCustomize.dependencies, dep)) { + continue; + } + + const dependencyVersionSpec = pkgToCustomize.dependencies[dep]; + if (dependencyVersionSpec.startsWith('workspace:')) { + let resolvedVersion: string | undefined; + const rangeSpecifier = dependencyVersionSpec.replace( + /^workspace:/, + '', + ); + const embeddedDep = options.embedded.find( + e => + e.packageName === dep && + checkWorkspacePackageVersion(dependencyVersionSpec, e), + ); + if (embeddedDep) { + resolvedVersion = embeddedDep.version; + } else if (options.monoRepoPackages) { + const relatedMonoRepoPackages = + options.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(dependencyVersionSpec, { + dir: relatedMonoRepoPackages[0].dir, + version: relatedMonoRepoPackages[0].packageJson.version, + }) + ) { + resolvedVersion = + rangeSpecifier === '^' || rangeSpecifier === '~' + ? rangeSpecifier + + relatedMonoRepoPackages[0].packageJson.version + : relatedMonoRepoPackages[0].packageJson.version; + } + } + + if (!resolvedVersion) { + throw new Error( + `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( + pkgToCustomize.name, + )} doesn't exist in the monorepo structure: maybe you should embed it ?`, + ); + } + + pkgToCustomize.dependencies[dep] = resolvedVersion; + } + + 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)}`; + } + } + } + } + + // 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 || {}), + }; + 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', + ...resolutions, + ...(options.additionalResolutions || {}), + }; + + if (options.after) { + options.after(pkgToCustomize); + } + + await fs.writeJson(dynamicPkgPath, pkgToCustomize, { + encoding: 'utf8', + spaces: 2, + }); + }; +} + +export async function locateAndCopyYarnLock({ + targetDir, + targetRoot, + yarnLock, +}: { + targetDir: string; + targetRoot: string; + yarnLock: string; +}) { + // 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(targetDir, 'yarn.lock'))) { + staticPluginYarnLock = path.join(targetDir, 'yarn.lock'); + } else if (await fs.pathExists(path.join(targetRoot, 'yarn.lock'))) { + staticPluginYarnLock = path.join(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( + targetRoot, + )})`, + ); + } + await fs.copyFile(staticPluginYarnLock, yarnLock); +} + +export function isPackageShared( + pkgName: string, + rules: SharedPackagesRules | undefined, +) { + const 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 embeddedPackageRelativePath(p: ResolvedEmbedded): string { + return path.join( + 'embedded', + p.packageName.replace(/^@/, '').replace(/\//, '-'), + ); +} diff --git a/src/commands/export-dynamic-plugin/frontend.ts b/src/commands/export-dynamic-plugin/frontend.ts index f17efc0..427b0f8 100644 --- a/src/commands/export-dynamic-plugin/frontend.ts +++ b/src/commands/export-dynamic-plugin/frontend.ts @@ -5,7 +5,7 @@ * 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 + * 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, @@ -14,26 +14,26 @@ * limitations under the License. */ -import { PackageRoleInfo } from '@backstage/cli-node'; import { buildFrontend } from '@backstage/cli/dist/modules/build/lib/buildFrontend.cjs.js'; - import { getPackages } from '@manypkg/get-packages'; import chalk from 'chalk'; import { OptionValues } from 'commander'; import fs from 'fs-extra'; - import path from 'path'; +import { execSync } from 'node:child_process'; 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, locateAndCopyYarnLock } from './common-utils'; -export async function frontend( - _: PackageRoleInfo, - opts: OptionValues, -): Promise { +/** + * The main entrypoint for exporting frontend Backstage plugins + * @param opts + * @returns + */ +export async function frontend(opts: OptionValues): Promise { const { name, version, @@ -47,29 +47,16 @@ export async function frontend( ); } - if (opts.generateModuleFederationAssets) { - if (opts.clean) { - await fs.remove(path.join(paths.targetDir, 'dist')); - } + // 1. Generate Module Federation Assets + await generateModuleFederationAssets(opts); - Task.log( - `Generating standard module federation assets in ${chalk.cyan( - path.join(paths.targetDir, 'dist'), - )}`, - ); - await buildFrontend({ - targetDir: paths.targetDir, - configPaths: [], - writeStats: false, - isModuleFederationRemote: true, - }); - } + // 2. Prepare Target Directory + const targetRelativePath = 'dist-dynamic'; + const target = path.resolve(paths.targetDir, targetRelativePath); - const distDynamicRelativePath = 'dist-dynamic'; - const target = path.resolve(paths.targetDir, distDynamicRelativePath); Task.log( `Packing main package to ${chalk.cyan( - path.join(distDynamicRelativePath, 'package.json'), + path.join(targetRelativePath, 'package.json'), )}`, ); @@ -78,23 +65,20 @@ export async function frontend( } await fs.mkdirs(target); - await fs.writeFile( - path.join(target, '.gitignore'), - ` -* -`, - ); + await fs.writeFile(path.join(target, '.gitignore'), `\n*\n`); await productionPack({ packageDir: paths.targetDir, targetDir: target, }); + // 3. Customize Package.json Task.log( `Customizing main package in ${chalk.cyan( - path.join(distDynamicRelativePath, 'package.json'), + path.join(targetRelativePath, 'package.json'), )} for dynamic loading`, ); + if ( files && Array.isArray(files) && @@ -103,75 +87,164 @@ export async function frontend( ) { files.push('dist-scalprum'); } + const monoRepoPackages = await getPackages(paths.targetDir); await customizeForDynamicUse({ embedded: [], isYarnV1: false, monoRepoPackages, - overridding: { + overriding: { name: `${name}-dynamic`, - // 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: {}, + scripts: {}, // Scripts removed to avoid npm pack triggers files, }, })(path.resolve(target, 'package.json')); - if (opts.generateScalprumAssets) { - const resolvedScalprumDistPath = path.join(target, 'dist-scalprum'); + // 4. Generate Scalprum Assets + await generateScalprumAssets(opts, target, name, version, scalprumInline); + + // 5. Handle Yarn Install / Lockfile + await handlePackageInstall(opts, target); + + return target; +} + +async function generateModuleFederationAssets(opts: OptionValues) { + if (!opts.generateModuleFederationAssets) return; + + if (opts.clean) { + await fs.remove(path.join(paths.targetDir, 'dist')); + } + + Task.log( + `Generating standard module federation assets in ${chalk.cyan( + path.join(paths.targetDir, 'dist'), + )}`, + ); + await buildFrontend({ + targetDir: paths.targetDir, + configPaths: [], + writeStats: false, + isModuleFederationRemote: true, + }); +} + +async function resolveScalprumConfig( + opts: OptionValues, + scalprumInline: any, + name: string, +) { + if (opts.scalprumConfig) { + const scalprumConfigFile = paths.resolveTarget(opts.scalprumConfig); Task.log( - `Generating dynamic frontend plugin assets in ${chalk.cyan( - resolvedScalprumDistPath, - )}`, + `Using external scalprum config file: ${chalk.cyan(scalprumConfigFile)}`, ); + return fs.readJson(scalprumConfigFile); + } - let scalprum: any = undefined; - if (opts.scalprumConfig) { - const scalprumConfigFile = paths.resolveTarget(opts.scalprumConfig); - Task.log( - `Using external scalprum config file: ${chalk.cyan(scalprumConfigFile)}`, - ); - scalprum = await fs.readJson(scalprumConfigFile); - } else if (scalprumInline) { - Task.log(`Using scalprum config inlined in the 'package.json'`); - scalprum = scalprumInline; - } else { - let scalprumName; - if (name.includes('/')) { - const fragments = name.split('/'); - scalprumName = `${fragments[0].replace('@', '')}.${fragments[1]}`; - } else { - scalprumName = name; - } - scalprum = { - name: scalprumName, - exposedModules: { - PluginRoot: './src/index.ts', - }, - }; - Task.log(`No scalprum config. Using default dynamic UI configuration:`); - Task.log(chalk.cyan(JSON.stringify(scalprum, null, 2))); - Task.log( - `If you wish to change the defaults, add "scalprum" configuration to plugin "package.json" file, or use the '--scalprum-config' option to specify an external config.`, - ); - } - - await fs.remove(resolvedScalprumDistPath); - - await buildScalprumPlugin({ - writeStats: false, - configPaths: [], - targetDir: paths.targetDir, - pluginMetadata: { - ...scalprum, - version, - }, + if (scalprumInline) { + Task.log(`Using scalprum config inlined in the 'package.json'`); + return scalprumInline; + } + + // Default configuration generation + let scalprumName; + if (name.includes('/')) { + const fragments = name.split('/'); + scalprumName = `${fragments[0].replace('@', '')}.${fragments[1]}`; + } else { + scalprumName = name; + } + + const defaultScalprum = { + name: scalprumName, + exposedModules: { + PluginRoot: './src/index.ts', + }, + }; + + Task.log(`No scalprum config. Using default dynamic UI configuration:`); + Task.log(chalk.cyan(JSON.stringify(defaultScalprum, null, 2))); + Task.log( + `If you wish to change the defaults, add "scalprum" configuration to plugin "package.json" file, or use the '--scalprum-config' option to specify an external config.`, + ); + return defaultScalprum; +} + +async function generateScalprumAssets( + opts: OptionValues, + target: string, + name: string, + version: string, + scalprumInline: any, +) { + if (!opts.generateScalprumAssets) return; + + const resolvedScalprumDistPath = path.join(target, 'dist-scalprum'); + Task.log( + `Generating dynamic frontend plugin assets in ${chalk.cyan( resolvedScalprumDistPath, + )}`, + ); + + const scalprum = await resolveScalprumConfig(opts, scalprumInline, name); + + await fs.remove(resolvedScalprumDistPath); + + await buildScalprumPlugin({ + writeStats: false, + configPaths: [], + targetDir: paths.targetDir, + pluginMetadata: { + ...scalprum, + version, + }, + resolvedScalprumDistPath, + }); +} + +async function handlePackageInstall(opts: OptionValues, target: string) { + const yarn = 'yarn'; + const yarnVersion = execSync(`${yarn} --version`).toString().trim(); // NOSONAR + const yarnLock = path.resolve(target, 'yarn.lock'); + const yarnLockExists = await fs.pathExists(yarnLock); + + if (!yarnLockExists) { + await locateAndCopyYarnLock({ + targetDir: paths.targetDir, + targetRoot: paths.targetRoot, + yarnLock, }); } - return target; + 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`, + ), + ); + return; + } + + Task.log( + `${yarnLockExists ? 'Verifying' : 'Creating'} filtered yarn.lock file for the exported package`, + ); + + const logFile = '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}`; + + await Task.forCommand(yarnInstall, { cwd: target, optional: false }); + await fs.remove(paths.resolveTarget('dist-dynamic', '.yarn')); + await fs.remove(paths.resolveTarget('dist-dynamic', logFile)); } diff --git a/src/commands/export-dynamic-plugin/types.ts b/src/commands/export-dynamic-plugin/types.ts new file mode 100644 index 0000000..3df1300 --- /dev/null +++ b/src/commands/export-dynamic-plugin/types.ts @@ -0,0 +1,12 @@ +export type SharedPackagesRules = { + include: (string | RegExp)[]; + exclude: (string | RegExp)[]; +}; + +export type ResolvedEmbedded = { + packageName: string; + version: string; + dir: string; + parentPackageName: string; + alreadyPacked: boolean; +};