From d802f1f500bde70fca6d8f1a53770695311ea37b Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 5 Dec 2025 10:31:02 -0500 Subject: [PATCH] feat(export): export consistent yarn project 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. A new .yarnrc.yml is created in the exported plugin package to ensure consistency when running "yarn install" during plugin export. Also workspace resolutions are taken from the adjacent package.json file when the yarn lockfile is discovered and applied to the derived exported package. This change also adds some documents that summarize the plugin export process. Finally this change adds a prepare script so that it's possible to run this command with changes from a pull request without having to perform a release for testing purposes. Assisted-By: Cursor Desktop rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- .eslintrc.js | 2 +- CHANGELOG.md | 91 ++- README.md | 4 + doc/plugin-export/README.md | 73 +++ doc/plugin-export/backend-export.md | 75 +++ doc/plugin-export/command-flow.md | 61 ++ doc/plugin-export/frontend-export.md | 50 ++ doc/plugin-export/shared-packaging.md | 71 +++ e2e-tests/setup.js | 4 +- package.json | 13 +- scripts/prepare.cjs | 36 ++ src/commands/export-dynamic-plugin/backend.ts | 306 ++------- src/commands/export-dynamic-plugin/command.ts | 4 +- .../export-dynamic-plugin/common-utils.ts | 595 ++++++++++++++++++ .../export-dynamic-plugin/frontend.ts | 290 ++++++--- src/commands/export-dynamic-plugin/types.ts | 12 + .../workspace-resolutions.test.ts | 110 ++++ src/commands/index.ts | 7 +- yarn.lock | 255 +------- 19 files changed, 1453 insertions(+), 606 deletions(-) create mode 100644 doc/plugin-export/README.md create mode 100644 doc/plugin-export/backend-export.md create mode 100644 doc/plugin-export/command-flow.md create mode 100644 doc/plugin-export/frontend-export.md create mode 100644 doc/plugin-export/shared-packaging.md create mode 100644 scripts/prepare.cjs create mode 100644 src/commands/export-dynamic-plugin/common-utils.ts create mode 100644 src/commands/export-dynamic-plugin/types.ts create mode 100644 src/commands/export-dynamic-plugin/workspace-resolutions.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index e9ea2f5..9f0654d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { - ignorePatterns: ['templates/**'], + ignorePatterns: ['templates/**', 'coverage/**', '**/lcov-report/**'], rules: { 'no-console': 0, }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c3c63..164218f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,90 @@ -# @red-hat-developer-hub/cli +# Changelog -## 0.0.2 + -### Patch Changes +All notable changes to `@red-hat-developer-hub/cli` are documented here. This project no longer uses Changesets; releases are versioned in `package.json` and described here (and in conventional commit messages / PR titles). -- 66e629b: fix missing node-stdlib-browser update in scalprumConfig.ts +## 1.10.3 + +### Added + +- **`prepare` lifecycle**: when `dist/` is missing (for example after cloning or installing from a git ref), runs `yarn build` so colleagues can try a PR via `npx` / `yarn dlx` without a separate build step. Skipped when `dist/` already exists (for example the published npm tarball). +- **Plugin export — workspace `resolutions`**: `plugin export` merges `resolutions` from the `package.json` next to the same `yarn.lock` used for the export (plugin package or monorepo root). Non-portable values (`workspace:`, `portal:`, `link:` at value start, and non-string shapes) are omitted with a warning. Merge order: built-in AWS workarounds, then packed manifest, then workspace, then backend `additionalResolutions` (embedded `file:` still wins on conflicts). +- **Documentation**: maintainer guide for the export pipeline under [`doc/plugin-export/`](doc/plugin-export/README.md). + +### Changed + +- **Frontend dynamic plugin export**: Yarn project setup and install behavior aligned with backend export (lockfile, **generated minimal** Berry **`.yarnrc.yml`**, logging where applicable). +- **Dependencies**: `fast-xml-parser` 5.5.7 (#87). + +### Fixed + +- **Yarn Berry `dist-dynamic/.yarnrc.yml`**: no longer a copy of the monorepo file; the exporter writes a **minimal generated** config (**`httpTimeout`** + **`nodeLinker: node-modules`**) so **`yarn install`** does not fail when the parent repo lists Berry **plugins** (e.g. under **`.yarn/plugins/`**) that are not present in the export tree. +- **Yarn lockfile** for frontend plugin export installs (`yarn.lock` consistency). + +## 1.10.2 + +### Changed + +- **@backstage/cli** updated to **0.35.4** (#86). + +### Added + +- **jest-environment-jsdom** (dev) for the test toolchain. + +## 1.10.1 + +### Changed — 1.10.1 + +- **Node.js**: CI and tooling moved to **Node 22** (#54). +- **Dependencies**: broad updates including **ESLint 9**, **webpack** / **@backstage/cli-common**, **axios**, **lodash** / **lodash-es**, **tar** (dev), **fast-xml-parser**, **ajv**, **undici**, **diff**, **jsonpath**, **node-forge**, **jws**, and others (Dependabot and manual bumps through #57–#62, #65–#66, #68–#69, #81, #83). + +### Fixed + +- **`plugin export`**: clearer handling when **`yarn install`** fails under `dist-dynamic` (log path, fail fast) — RHDHBUGS-2819 (#70, #73; see also #77). + +## 1.10.0 + +### Changed + +- **Version** aligned with RHDH **1.10** line. + +### Fixed + +- **E2E tests** for community plugin build (#52). +- **Webpack** dependency alignment across the tree (#39). + +## 1.9.1 + +### Fixed — 1.9.1 + +- **Webpack** / **webpack-dev-server** version inconsistency in published metadata (#35 follow-up). + +## 1.9.0 + +### Added + +- **`--generate-module-federation-assets`** (and inverse) for **frontend** `plugin export`, emitting standard Module Federation assets alongside Scalprum (#31). +- **`generate-types`** script and **Backstage CLI** dependency bump for the build. + +### Changed + +- **Version** aligned with RHDH **1.9** line. + +### Fixed + +- **CLI binary** name / packaging (`rhdh-cli`) (#26). + +## 1.8.0 and earlier + +Releases **1.8.x** and below used the same tagging process (`tagRelease.sh` / automated bump PRs). Notable older changes include: + +- **`plugin package`**: correct export command in user-facing text (#22). +- **README**: versioning notes; move away from Changesets toward conventional commits (#20). +- **E2E**: optional archive download instead of git clone for community plugin build tests. + +## Historic (0.x) + +### 0.0.2 + +- Fix missing **node-stdlib-browser** update in `scalprumConfig.ts` (66e629b; Changesets-era entry). diff --git a/README.md b/README.md index d9a0136..7a3ac46 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ This repository hosts the source code for the rhdh-cli utility, a new command-li This new CLI aims to offer more flexibility and ease of use compared to the previous @janus-idp/cli. +## Documentation + +- **[Plugin export pipeline](doc/plugin-export/README.md)** — how `plugin export` works for backend and frontend plugins (`dist-dynamic`, packaging, and related CLI flags). + > [!TIP] diff --git a/doc/plugin-export/README.md b/doc/plugin-export/README.md new file mode 100644 index 0000000..fbaf63f --- /dev/null +++ b/doc/plugin-export/README.md @@ -0,0 +1,73 @@ +# `plugin export` — documentation index + +This folder describes how **`rhdh-cli plugin export`** repackages a Backstage plugin into **`./dist-dynamic`** for use as a dynamic plugin. It is aimed at maintainers and contributors who need to understand or explain the pipeline. + +## Quick links + +| Document | Contents | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| [command-flow.md](./command-flow.md) | Shared steps after backend vs frontend dispatch: config schema, `supported-versions`, `--dev` | +| [backend-export.md](./backend-export.md) | Backend / backend-module export phases (`backend.ts`) | +| [frontend-export.md](./frontend-export.md) | Frontend / frontend-module export phases (`frontend.ts`) | +| [shared-packaging.md](./shared-packaging.md) | `productionPack`, `customizeForDynamicUse`, workspace `resolutions` inheritance, `initializeYarnProject` | + +## Command and entrypoints + +- **CLI registration**: [`src/commands/index.ts`](../../src/commands/index.ts) (`plugin export` subcommand and flags). +- **Orchestrator**: [`src/commands/export-dynamic-plugin/command.ts`](../../src/commands/export-dynamic-plugin/command.ts) — reads `backstage.role`, calls `backend()` or `frontend()`, then writes config schema, checks `supported-versions`, applies `--dev`. + +**Example** (from the plugin package directory): + +```bash +npx @red-hat-developer-hub/cli plugin export +``` + +## Prerequisites + +- The **current working directory** (or Backstage CLI target) must be a package whose **`package.json`** defines **`backstage.role`**. +- **Supported roles** (see `command.ts`): `backend-plugin`, `backend-plugin-module`, `frontend-plugin`, `frontend-plugin-module`. Other roles are rejected. + +## Output + +- Primary artifact: **`dist-dynamic/`** under the plugin package, containing a derived **`package.json`**, optional **`yarn.lock`** / **`.yarnrc.yml`**, **`packageManager`**, and built assets (layout differs by role; see backend vs frontend docs). +- **Config schema** JSON is written under paths that depend on role (see [command-flow.md](./command-flow.md)). + +## CLI options matrix + +Flags are registered on `plugin export` for all roles; **only some are honored** depending on implementation. + +| Option | Backend | Frontend | Notes | +| -------------------------------------------------------------------------------- | :-----: | :------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--embed-package` | Yes | — | | +| `--shared-package` | Yes | — | Moves matching deps to `peerDependencies` (plus default `@backstage/*`). | +| `--allow-native-package` | Yes | — | | +| `--suppress-native-package` | Yes | — | | +| `--ignore-version-check` | Yes | — | Relaxes semver checks when embedding / hoisting peers. | +| `--no-install` / `--install` | Yes | Yes | Commander exposes `--no-install`. Backend help says “backend only” but **frontend** [`handlePackageInstall`](../../src/commands/export-dynamic-plugin/frontend.ts) also respects it. | +| `--no-build` / `--build` | Yes | — | | +| `--clean` | Yes | Yes | | +| `--dev` | Yes | Yes | Symlink `src` for **node** platform; frontend gets symlink/copy without `src` link. See [command-flow.md](./command-flow.md). | +| `--dynamic-plugins-root` | Yes | Yes | Used with `--dev` when copying instead of symlinking. | +| `--scalprum-config` | — | Yes | | +| `--track-dynamic-manifest-and-lock-file` | Yes | Yes | Whitelists `package.json`, `yarn.lock`, and `.yarnrc.yml` in `dist-dynamic/.gitignore`. Name refers to manifest + lockfile; **`.yarnrc.yml` is included** for Yarn Berry standalone config. | +| `--generate-scalprum-assets` / `--no-generate-scalprum-assets` | — | Yes | | +| `--generate-module-federation-assets` / `--no-generate-module-federation-assets` | — | Yes | At least one of Scalprum or MF must stay enabled. | + +For exact strings, see [`src/commands/index.ts`](../../src/commands/index.ts). + +## Source layout (reference) + +```text +src/commands/export-dynamic-plugin/ + command.ts # role dispatch + post-export steps + backend.ts # backend export implementation + frontend.ts # frontend export implementation + common-utils.ts # customizeForDynamicUse, initializeYarnProject + dev.ts # --dev symlink / install into dynamic plugins root + backend-utils.ts + types.ts +``` + +## Related commands + +- **`plugin package`** — builds a container image / registry workflow from exported plugins ([`src/commands/index.ts`](../../src/commands/index.ts)); not covered here. diff --git a/doc/plugin-export/backend-export.md b/doc/plugin-export/backend-export.md new file mode 100644 index 0000000..7d37eb5 --- /dev/null +++ b/doc/plugin-export/backend-export.md @@ -0,0 +1,75 @@ +# Backend dynamic plugin export + +Walkthrough of [`backend.ts`](../../src/commands/export-dynamic-plugin/backend.ts): **`backend-plugin`** and **`backend-plugin-module`** packages. The result is a self-contained tree under **`dist-dynamic/`** suitable for loading as a Node dynamic plugin. + +← [Back to index](./README.md) · Shared packaging: [shared-packaging.md](./shared-packaging.md) + +## 1. Inputs and guards + +- Parse the plugin **`package.json`**; reject if **`bundled: true`** (dynamic backend plugins must not be bundled in this sense). +- Resolve CLI lists: **`--embed-package`**, **`--shared-package`**, **`--allow-native-package`**, **`--suppress-native-package`**, **`--ignore-version-check`**. +- Load monorepo layout with **`@manypkg/get-packages`** from **`paths.targetDir`**. +- **`searchEmbedded`** ([`backend.ts`](../../src/commands/export-dynamic-plugin/backend.ts) + [`backend-utils`](../../src/commands/export-dynamic-plugin/backend-utils.ts)) resolves which packages to embed from the dependency graph and optional **`--embed-package`** names; may auto-include related **`-common`** / **`-node`** packages depending on role. + +## 2. Shared dependency rules + +- **`sharedPackagesRules`**: by default, **`@backstage/*`** dependencies are treated as **shared** (moved to **`peerDependencies`** during [`customizeForDynamicUse`](./shared-packaging.md)). +- **`--shared-package`** adds patterns (string or `/regex/`); entries prefixed with **`!`** go to **`exclude`** (do not force to peer). +- **Embedded package names** are always excluded from the “move to peer” rule so they stay private/embedded as intended. + +## 3. Prepare `dist-dynamic` + +- Optional **`--clean`**: remove **`dist-dynamic`** entirely. +- Recreate directory and write **`.gitignore`** (ignore all by default). +- With **`--track-dynamic-manifest-and-lock-file`**, whitelist **`package.json`**, **`yarn.lock`**, and **`.yarnrc.yml`** in **`dist-dynamic/.gitignore`** so they can be committed (productization). The flag name refers to manifest and lockfile; **`.yarnrc.yml` is included** for the generated minimal Yarn Berry config. + +## 4. Suppress native (`--suppress-native-package`) + +For each suppressed name, materialize a stub under **`embedded//`** (minimal **`package.json`** + **`index.js`** that throws) so resolutions can point at a non-native placeholder. + +## 5. Embedded packages loop + +For each resolved embedded package: + +- Optional **`yarn build`** in the embedded package dir when **`--build`** (default install path uses `opts.build`). +- **`productionPack`** into **`dist-dynamic/embedded//`**, or **recursive copy** if already packed. +- Remove **`node_modules`** under the embedded copy if present. +- **`customizeForDynamicUse`** on the embedded **`package.json`** (private, version suffix `+embedded`, shared/peer rules, workspace resolution, Yarn v1 **`file:`** for embedded deps when applicable), including [lockfile-adjacent workspace **`resolutions`**](./shared-packaging.md#workspace-resolutions-inheritance) merged before backend-only **`additionalResolutions`**. +- Collect **peer dependencies** from embedded packages for later hoisting onto the main package. + +## 6. Main package + +- Optional **`yarn build`** at **`paths.targetDir`** when **`--build`**. +- **`productionPack`** with **`packageDir: ''`** so file resolution uses the **Backstage CLI target directory** (plugin root) as the pack root, output into **`dist-dynamic`**. +- Remove nested **`dist-dynamic/dist-dynamic`** if **`files`** accidentally included it. +- **`customizeForDynamicUse`** on the main **`package.json`** (same [workspace \*\*`resolutions` inheritance](./shared-packaging.md#workspace-resolutions-inheritance); embedded **`file:`** resolutions still win on conflicts): + - Rename to **`-dynamic`** + - **`bundleDependencies: true`** + - Clear **`scripts`** + - **`resolutions`** / **`yarn`** resolutions wiring **`file:./embedded/...`** for embedded packages (and suppressed native stubs) + - Hoist collected embedded **peer** requirements onto the main **`peerDependencies`** when non-empty + +Details of manifest rewriting: [shared-packaging.md](./shared-packaging.md). + +## 7. Yarn project metadata (`initializeYarnProject`) + +See [shared-packaging.md](./shared-packaging.md). Summaries: copy **`yarn.lock`** if missing, **generate** a minimal Berry **`.yarnrc.yml`** in **`dist-dynamic`** (**`httpTimeout`** + **`nodeLinker: node-modules`**; see shared doc), set **`packageManager`**. + +## 8. `yarn install` + +- Skipped when **`--no-install`**; user is warned the lockfile may be stale until they install manually. +- Otherwise run **`yarn install`** in **`dist-dynamic`** with Yarn **1.x** vs **Berry**-appropriate flags; log to a temp file on backend, then remove **`.yarn`** under **`dist-dynamic`** after success. + +## 9. Post-install validation (install path only) + +1. **Shared vs private**: scan **`yarn.lock`** so no **shared** package (per rules) appears as a private dependency; on failure, suggest **`--shared-package !...`** or **`--embed-package`** with a derived hint list. +2. **Native modules**: **`gatherNativeModules`**; fail unless listed in **`--allow-native-package`**. +3. **Entry points**: **`validatePluginEntryPoints`** ([`backend-utils.ts`](../../src/commands/export-dynamic-plugin/backend-utils.ts)) — ensures expected backend plugin entry surface. + +On success, the yarn install log is removed (see **`logFile`** / **`fs.remove`** usage at the end of the install block in **`backend.ts`**). + +## 10. Return value + +Returns the absolute **`dist-dynamic`** path for **`command.ts`** to append config schema and dev steps. + +← [Back to index](./README.md) diff --git a/doc/plugin-export/command-flow.md b/doc/plugin-export/command-flow.md new file mode 100644 index 0000000..bb2dfab --- /dev/null +++ b/doc/plugin-export/command-flow.md @@ -0,0 +1,61 @@ +# Shared `plugin export` command flow + +This document describes steps in [`command.ts`](../../src/commands/export-dynamic-plugin/command.ts) that run **after** the backend or frontend exporter returns the `dist-dynamic` path. + +← [Back to index](./README.md) + +## High-level diagram + +```mermaid +flowchart TD + start[command.ts] --> readPkg[Read package.json and role] + readPkg --> branch{Role} + branch -->|backend-plugin or module| backendFn[backend opts] + branch -->|frontend-plugin or module| frontendFn[frontend opts] + backendFn --> targetPath[dist-dynamic path] + frontendFn --> targetPath + targetPath --> schemaPaths[Compute configSchema target paths] + schemaPaths --> writeSchema[Write config schema JSON via getConfigSchema] + writeSchema --> supported[checkBackstageSupportedVersions] + supported --> devOpts[applyDevOptions from dev.ts] +``` + +## Role dispatch + +1. Load the target **`package.json`** and resolve **`backstage.role`** via `@backstage/cli-node` `PackageRoles`. +2. If role is **`backend-plugin`** or **`backend-plugin-module`**, call **`backend(opts)`** and set config schema paths to: + - `dist-dynamic/dist/configSchema.json` + - `dist-dynamic/dist/.config-schema.json` +3. If role is **`frontend-plugin`** or **`frontend-plugin-module`**, call **`frontend(opts)`**. Config schema paths are built dynamically: + - If `dist-dynamic/dist-scalprum` exists → add `dist-scalprum/configSchema.json` + - If `dist-dynamic/dist` exists → add `dist/.config-schema.json` +4. Any other role → **error** (not supported for this command). + +## Config schema emission + +- **`getConfigSchema(rawPkg.name)`** ([`lib/schema/collect`](../../src/lib/schema/collect.ts)) produces the schema payload. +- For **each** computed path, the command writes **`paths.resolveTarget(configSchemaPath)`** (JSON, 2-space indent) so files land under the CLI target package (typically next to the generated **`dist`** / **`dist-scalprum`** trees). + +## `backstage.supported-versions` (`checkBackstageSupportedVersions`) + +Runs only when **`backstage.json`** exists at the **monorepo root** (`paths.targetRoot`). + +- Reads **`version`** from `backstage.json`. +- If the derived **`dist-dynamic/package.json`** already has **`backstage.supported-versions`**: + - If it is a **single semver** and the range is **incompatible** with `~backstageVersion` → **throws**. + - If it is a **range** and incompatible → **warning** and the field is **overwritten** with `backstageVersion`. +- If **`supported-versions` is missing** → it is **set** to `backstageVersion` from `backstage.json`. + +## `--dev` (`applyDevOptions`) + +Implemented in [`dev.ts`](../../src/commands/export-dynamic-plugin/dev.ts). No-op unless **`--dev`** is set. + +1. **Node platform** (`backend-plugin`, `backend-plugin-module`): create a **directory symlink** from the plugin’s **`src`** to **`dist-dynamic/src`** so source maps resolve during local runs. +2. **Dynamic plugins root**: + - If **`--dynamic-plugins-root`** is passed → that directory is used; the exporter **copies** `dist-dynamic` into + `/-dynamic` (backend) or without the `-dynamic` suffix for **frontend** roles. + - If **`--dynamic-plugins-root`** is **omitted** → load app config from **`paths.targetRoot`**, read **`dynamicPlugins.rootDirectory`**, resolve to an absolute path, then **symlink** `dist-dynamic` to the same destination naming rule. Missing `dynamicPlugins.rootDirectory` → **error**. + +When copying (explicit root), the root folder is created if needed and a **`.gitignore`** with `*` may be added there. + +← [Back to index](./README.md) diff --git a/doc/plugin-export/frontend-export.md b/doc/plugin-export/frontend-export.md new file mode 100644 index 0000000..00e3f90 --- /dev/null +++ b/doc/plugin-export/frontend-export.md @@ -0,0 +1,50 @@ +# Frontend dynamic plugin export + +Walkthrough of [`frontend.ts`](../../src/commands/export-dynamic-plugin/frontend.ts): **`frontend-plugin`** and **`frontend-plugin-module`** packages. Output lives under **`dist-dynamic/`** and may include **Scalprum** and/or **Module Federation** assets under the plugin tree. + +← [Back to index](./README.md) · Shared packaging: [shared-packaging.md](./shared-packaging.md) + +## 1. Asset modes (CLI) + +- At least one of **`--generate-scalprum-assets`** or **`--generate-module-federation-assets`** must remain enabled (defaults: both **true** in [`index.ts`](../../src/commands/index.ts)); otherwise the command throws. +- **`--scalprum-config `** overrides inline **`scalprum`** config in **`package.json`** for Scalprum generation. + +## 2. Optional Module Federation assets + +When **`--generate-module-federation-assets`** is on: + +- With **`--clean`**, remove the plugin’s **`dist/`** first. +- Call Backstage **`buildFrontend`** with **`isModuleFederationRemote: true`**, writing the standard MF remote bundle into **`paths.targetDir/dist/`**. + +## 3. `dist-dynamic` directory + +- Path: **`paths.targetDir/dist-dynamic`**. +- **`--clean`**: remove **`dist-dynamic`** first. +- Write **`.gitignore`** (ignore all; optional whitelist with **`--track-dynamic-manifest-and-lock-file`**: **`package.json`**, **`yarn.lock`**, and **`.yarnrc.yml`** — the flag name only says manifest and lockfile, but **`.yarnrc.yml` is included** so the minimal Berry rc can be committed with them; same behavior as backend). +- **`productionPack`** from **`paths.targetDir`** into **`dist-dynamic`** ([shared-packaging.md](./shared-packaging.md)). + +## 4. Customize main `package.json` + +- **`customizeForDynamicUse`** with **no embedded packages** and **`isYarnV1: false`**, after loading [lockfile-adjacent workspace **`resolutions`**](./shared-packaging.md#workspace-resolutions-inheritance): + - **`name`** → **`-dynamic`** + - **`scripts`** cleared + - **`files`**: ensure **`dist-scalprum`** is listed when Scalprum generation is enabled and not already present +- Workspace **`workspace:`** dependencies are resolved to concrete versions using monorepo **`getPackages`**. + +## 5. Optional Scalprum assets + +When **`--generate-scalprum-assets`** is on: + +- Remove previous **`dist-dynamic/dist-scalprum`**. +- **`buildScalprumPlugin`**: emits Scalprum bundle/metadata into **`dist-dynamic/dist-scalprum`**, using resolved config (file, **`package.json`**, or defaults documenting **`exposedModules`**, etc.). + +## 6. Yarn project and install (`handlePackageInstall`) + +- **`initializeYarnProject`**: lockfile, **generated minimal** Berry **`.yarnrc.yml`**, **`packageManager`** ([shared-packaging.md](./shared-packaging.md)). +- **`--no-install`**: skip **`yarn install`** and warn; otherwise run install in **`dist-dynamic`** with Yarn 1 vs Berry flags, then remove **`.yarn`** and the local **`yarn-install.log`** under **`dist-dynamic`**. + +## 7. Return value + +Returns the **`dist-dynamic`** path for **`command.ts`** (config schema paths depend on which of **`dist/`** / **`dist-scalprum`** exist). + +← [Back to index](./README.md) diff --git a/doc/plugin-export/shared-packaging.md b/doc/plugin-export/shared-packaging.md new file mode 100644 index 0000000..1b075e2 --- /dev/null +++ b/doc/plugin-export/shared-packaging.md @@ -0,0 +1,71 @@ +# Shared packaging helpers + +Utilities used by both backend and frontend export paths. + +← [Back to index](./README.md) + +## `productionPack` + +**Source:** [`src/lib/packager/productionPack.ts`](../../src/lib/packager/productionPack.ts) + +- Reads the source **`package.json`**, applies publish/manifest normalization (e.g. hoists relevant **`publishConfig`** fields, prepares **`exports`** from **`dist/`** when present via **`readEntryPoints`**). +- When **`targetDir`** is set, uses **`npm-packlist`** to enumerate files that would ship in an npm tarball (respects **`files`**, **`.npmignore`**, etc.) and **copies** them into **`targetDir`**, writing the **mutated `package.json`** into that tree. +- **Backend main package** calls it with **`packageDir`** as the empty string so paths resolve against the **Backstage CLI target directory** (the plugin root). **Embedded** copies use the embedded package’s directory. **Frontend** passes **`paths.targetDir`**. + +This is the primary mechanism that materializes **`dist-dynamic`** file layout before manifest customization runs. + +## `customizeForDynamicUse` + +**Source:** [`src/commands/export-dynamic-plugin/common-utils.ts`](../../src/commands/export-dynamic-plugin/common-utils.ts) + +Higher-order function returning an async closure: **`(dynamicPkgPath) => Promise`**. + +Applied to **`dist-dynamic/package.json`** (and embedded **`package.json`** files on the backend). Notable behaviors: + +- Applies **`overriding`** fields from the caller (name, scripts, `bundleDependencies`, etc.). +- Strips **`dist-dynamic/`** from **`files`** entries. +- Resolves **`workspace:`** dependency specs to concrete versions using **`embedded`** list and **`@manypkg/get-packages`** monorepo data; throws if a workspace dep cannot be resolved. +- **Shared packages** (rules from backend; frontend uses default movement only where configured): matching dependencies move to **`peerDependencies`**. +- **Yarn 1.x** (`isYarnV1`): embedded deps can be rewritten to **`file:./embedded/...`**. +- Clears **`devDependencies`** so the dynamic package is production-oriented. +- Merges known **`overrides`** / **`resolutions`** workarounds (e.g. AWS SDK utf8 packages). +- Merges optional **`workspaceResolutions`** (see [Workspace `resolutions` inheritance](#workspace-resolutions-inheritance) below). +- Optional **`after`** hook for callers (backend uses it to hoist embedded peers). + +## Workspace `resolutions` inheritance + +**Source:** [`common-utils.ts`](../../src/commands/export-dynamic-plugin/common-utils.ts) — **`loadResolutionsFromYarnLockWorkspace`**, used from backend and frontend export before **`customizeForDynamicUse`**. + +When the derived **`dist-dynamic/package.json`** is built, **`yarn install`** there uses a **`yarn.lock`** copied from the same place the exporter already chooses: **`yarn.lock`** next to the plugin package if present, otherwise the monorepo root. **`loadResolutionsFromYarnLockWorkspace`** reads **`resolutions`** from the **`package.json`** in that same directory (the “lockfile-adjacent” manifest). If there is no lockfile or no valid plain-object **`resolutions`** field, nothing is added. + +**Merge precedence** for the final **`resolutions`** object inside **`customizeForDynamicUse`**: + +1. Built-in AWS-related pins (utf8 workaround). +2. **`resolutions`** from the **packed** plugin manifest (after other manifest transforms). +3. **`workspaceResolutions`** loaded as above (monorepo / root policy overrides plugin pins when keys clash). +4. **`additionalResolutions`** from the caller — **always wins** on duplicate keys (backend: embedded **`file:./embedded/...`** and suppress-native stubs must stay authoritative). + +**Sanitization:** Entries whose values use monorepo-only Yarn protocols at the start of the string (**`workspace:`**, **`portal:`**, **`link:`**, case-insensitive, ignoring leading whitespace) are **not** copied into the derived package; they would not resolve in standalone **`dist-dynamic`**. Non-string values (nested objects, arrays) are also omitted because only a **shallow** key merge is supported. When anything is omitted, the CLI logs a **single warning** listing the dropped keys. + +After sanitization, **all** remaining inherited entries are merged into the derived manifest. That includes pins that only affect **transitive** dependencies (for example workspace-wide CVE overrides), even when the exported package does not list that package name directly. The trade-off is that unrelated root pins (e.g. **`react`** on a backend-only plugin) may still appear in **`dist-dynamic/package.json`**; they are inert for **`yarn install`** unless something in the install graph matches them. + +**Limitations (not changed in this feature):** + +- **Nested or non-scalar `resolutions` values** (as allowed in some Yarn versions): exotic structures are not deep-merged; only portable flat string entries are inherited as-is. +- Root **`overrides`** in **`package.json`** (npm / parity with other tools) are **not** copied; installs still rely on **`resolutions`** plus the lockfile. If you depend on root-only **`overrides`**, duplicate that policy in **`resolutions`** or extend the export separately. + +## `initializeYarnProject` + +**Source:** same [`common-utils.ts`](../../src/commands/export-dynamic-plugin/common-utils.ts). + +Runs before **`yarn install`** in **`dist-dynamic`** (and still runs when install is skipped, so metadata is present): + +1. **`yarn.lock`**: if missing, copy from the plugin directory or monorepo root (same search order as the previous lock-only helper). +2. **`.yarnrc.yml`** (Yarn Berry semantics): the file under **`dist-dynamic`** is **generated**, not copied from the monorepo. For Yarn **> 1**, the exporter always writes **`httpTimeout`** (long timeout for large lockfiles) and **`nodeLinker: node-modules`** so **`yarn install`** runs as a standalone project and does **not** load monorepo-only Berry **plugins** (for example paths under **`.yarn/plugins/`** that are absent in **`dist-dynamic`**). If the YAML is missing or invalid, it is replaced with that same minimal shape. **Yarn 1**: no synthetic **`.yarnrc.yml`**; **`nodeLinker`** enforcement is a no-op. +3. **`packageManager`**: set on **`package.json`** to **`yarn@`**, preferring semver parsed from Berry **`yarnPath`** when that can be read from a **pre-existing** **`dist-dynamic/.yarnrc.yml`** (packed artifact edge case) or from the workspace **`.yarnrc.yml`** next to the plugin or repo root (**read-only**; the file is not copied into **`dist-dynamic`**), else **`yarn --version`**. + +This keeps **`dist-dynamic`** recognizable as a standalone Yarn project (lockfile + config + Corepack **`packageManager`**) for installs and tooling such as SBOM generation. + +**Versioning `dist-dynamic` Yarn metadata:** pass **`--track-dynamic-manifest-and-lock-file`** to **`plugin export`**. The CLI writes **`dist-dynamic/.gitignore`** as ignore-all with negated entries for **`package.json`**, **`yarn.lock`**, and **`.yarnrc.yml`** so those files can be committed. The option name highlights the manifest and lockfile; **`.yarnrc.yml` is part of the same whitelist** (minimal generated Berry settings, not the monorepo’s full rc). + +← [Back to index](./README.md) diff --git a/e2e-tests/setup.js b/e2e-tests/setup.js index e8fc715..e0eab5b 100644 --- a/e2e-tests/setup.js +++ b/e2e-tests/setup.js @@ -1,7 +1,7 @@ // Plain console output for e2e tests (no Jest "console.log" label or stack trace) console.log = (...args) => { - process.stdout.write(args.map(String).join(' ') + '\n'); + process.stdout.write(`${args.map(String).join(' ')}\n`); }; console.error = (...args) => { - process.stderr.write(args.map(String).join(' ') + '\n'); + process.stderr.write(`${args.map(String).join(' ')}\n`); }; diff --git a/package.json b/package.json index f8a96c6..e6faef9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@red-hat-developer-hub/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "1.10.2", + "version": "1.10.3", "publishConfig": { "access": "public" }, @@ -21,6 +21,7 @@ "license": "Apache-2.0", "main": "dist/index.cjs.js", "scripts": { + "prepare": "node scripts/prepare.cjs", "build": "backstage-cli package build", "lint:check": "backstage-cli package lint", "lint:fix": "backstage-cli package lint --fix", @@ -58,7 +59,7 @@ "css-loader": "^6.5.1", "esbuild": "^0.25.0", "esbuild-loader": "^2.18.0", - "eslint": "^9.26.0", + "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", "eslint-webpack-plugin": "^3.2.0", "fork-ts-checker-webpack-plugin": "^7.0.0-alpha.8", @@ -91,6 +92,8 @@ "@backstage/core-plugin-api": "1.10.3", "@backstage/repo-tools": "^0.13.3", "@jest/globals": "^30.0.0-beta.3", + "@spotify/eslint-config-base": "15.0.0", + "@spotify/eslint-config-typescript": "15.0.0", "@spotify/prettier-config": "15.0.0", "@types/fs-extra": "9.0.13", "@types/jest": "^29.5.14", @@ -100,7 +103,13 @@ "@types/recursive-readdir": "2.2.4", "@types/tar": "^6.1.1", "@types/yarnpkg__lockfile": "1.1.9", + "@typescript-eslint/eslint-plugin": "^8.17.0", + "@typescript-eslint/parser": "^8.16.0", "axios": "^1.9.0", + "eslint-plugin-deprecation": "^3.0.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.9.0", + "eslint-plugin-unused-imports": "^4.1.4", "jest": "^29.7.0", "jest-environment-jsdom": "^30.3.0", "mock-fs": "5.2.0", diff --git a/scripts/prepare.cjs b/scripts/prepare.cjs new file mode 100644 index 0000000..099f83c --- /dev/null +++ b/scripts/prepare.cjs @@ -0,0 +1,36 @@ +/** + * `prepare` lifecycle: build when `dist/` is missing (git clone, PR via npx/git install). + * Skip when `dist/` exists (published tarball) so installs from the registry do not rebuild + * or require devDependency-only tooling. + */ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const distMain = path.join(root, 'dist', 'index.cjs.js'); + +if (fs.existsSync(distMain)) { + process.exit(0); +} + +const backstageCli = path.join( + root, + 'node_modules', + '@backstage', + 'cli', + 'bin', + 'backstage-cli', +); + +if (!fs.existsSync(backstageCli)) { + console.error( + 'prepare: missing node_modules/@backstage/cli — run yarn install before prepare', + ); + process.exit(1); +} + +execFileSync(process.execPath, [backstageCli, 'package', 'build'], { + stdio: 'inherit', + cwd: root, +}); diff --git a/src/commands/export-dynamic-plugin/backend.ts b/src/commands/export-dynamic-plugin/backend.ts index bf5c477..e0c9516 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 os from 'node:os'; import * as path from 'path'; @@ -38,6 +38,15 @@ import { gatherNativeModules, isValidPluginModule, } from './backend-utils'; +import { + checkWorkspacePackageVersion, + customizeForDynamicUse, + embeddedPackageRelativePath, + initializeYarnProject, + isPackageShared, + loadResolutionsFromYarnLockWorkspace, +} from './common-utils'; +import { ResolvedEmbedded, SharedPackagesRules } from './types'; export async function backend(opts: OptionValues): Promise { const targetRelativePath = 'dist-dynamic'; @@ -106,6 +115,11 @@ export async function backend(opts: OptionValues): Promise { exclude: dontMoveToPeerDependencies, }; + const workspaceResolutions = await loadResolutionsFromYarnLockWorkspace({ + pluginPkgDir: paths.targetDir, + monorepoRoot: paths.targetRoot, + }); + if (opts.clean) { await fs.remove(target); } @@ -120,6 +134,7 @@ ${ ? ` !package.json !yarn.lock +!.yarnrc.yml ` : '' }`, @@ -210,7 +225,8 @@ throw new Error( isYarnV1: yarnVersion.startsWith('1.'), monoRepoPackages, sharedPackages: sharedPackagesRules, - overridding: { + workspaceResolutions, + overriding: { private: true, version: `${embedded.version}+embedded`, }, @@ -277,7 +293,8 @@ throw new Error( isYarnV1: yarnVersion.startsWith('1.'), monoRepoPackages, sharedPackages: sharedPackagesRules, - overridding: { + workspaceResolutions, + overriding: { name: derivedPackageName, bundleDependencies: true, // We remove scripts, because they do not make sense for this derived package. @@ -317,42 +334,13 @@ throw new Error( 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`, - ), - ); - } - } + await initializeYarnProject({ + pluginPkgDir: paths.targetDir, + monorepoRoot: paths.targetRoot, + exportDir: target, + yarnVersion, + copyYarnLockIfMissing: !yarnLockExists, + }); if (opts.install) { Task.log(`Installing private dependencies of the main package`); @@ -485,18 +473,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[], @@ -652,219 +644,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`); @@ -955,10 +734,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..200eddb --- /dev/null +++ b/src/commands/export-dynamic-plugin/common-utils.ts @@ -0,0 +1,595 @@ +import chalk from 'chalk'; +import fs from 'fs-extra'; +import YAML from 'yaml'; + +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 type CustomizeForDynamicUseOptions = { + embedded: ResolvedEmbedded[]; + isYarnV1: boolean; + monoRepoPackages?: Packages; + sharedPackages?: SharedPackagesRules; + overriding?: Partial & { + bundleDependencies?: boolean; + }; + additionalOverrides?: { [key: string]: any }; + /** From lockfile-adjacent package.json; merged after packed manifest resolutions, before additionalResolutions. */ + workspaceResolutions?: { [key: string]: any }; + additionalResolutions?: { [key: string]: any }; + after?: (pkg: BackstagePackageJson) => void; +}; + +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(/\//, '-'), + ); +} + +function resolveWorkspaceDependencyVersion( + dep: string, + dependencyVersionSpec: string, + packageName: string | undefined, + embedded: ResolvedEmbedded[], + monoRepoPackages: Packages | undefined, +): string { + const rangeSpecifier = dependencyVersionSpec.replace(/^workspace:/, ''); + const embeddedDep = embedded.find( + e => + e.packageName === dep && + checkWorkspacePackageVersion(dependencyVersionSpec, e), + ); + if (embeddedDep) { + return embeddedDep.version; + } + if (!monoRepoPackages) { + throw new Error( + `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( + packageName ?? '', + )} doesn't exist in the monorepo structure: maybe you should embed it ?`, + ); + } + 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 === 0) { + throw new Error( + `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( + packageName ?? '', + )} doesn't exist in the monorepo structure: maybe you should embed it ?`, + ); + } + const mono = relatedMonoRepoPackages[0]; + if ( + !checkWorkspacePackageVersion(dependencyVersionSpec, { + dir: mono.dir, + version: mono.packageJson.version, + }) + ) { + throw new Error( + `Workspace dependency ${chalk.cyan(dep)} of package ${chalk.cyan( + packageName ?? '', + )} doesn't exist in the monorepo structure: maybe you should embed it ?`, + ); + } + return rangeSpecifier === '^' || rangeSpecifier === '~' + ? rangeSpecifier + mono.packageJson.version + : mono.packageJson.version; +} + +function processDependencyForDynamicUse( + dep: string, + pkgToCustomize: BackstagePackageJson, + options: CustomizeForDynamicUseOptions, +): void { + const deps = pkgToCustomize.dependencies; + if (!deps || !Object.hasOwn(deps, dep)) { + return; + } + const specAtStart = deps[dep]; + + if (specAtStart.startsWith('workspace:')) { + deps[dep] = resolveWorkspaceDependencyVersion( + dep, + specAtStart, + pkgToCustomize.name, + options.embedded, + options.monoRepoPackages, + ); + } + + if (isPackageShared(dep, options.sharedPackages)) { + Task.log(` moving ${chalk.cyan(dep)} to peerDependencies`); + pkgToCustomize.peerDependencies ||= {}; + pkgToCustomize.peerDependencies[dep] = deps[dep]; + delete deps[dep]; + return; + } + + if (options.isYarnV1) { + const embeddedDep = options.embedded.find( + e => + e.packageName === dep && checkWorkspacePackageVersion(specAtStart, e), + ); + if (embeddedDep) { + deps[dep] = `file:./${embeddedPackageRelativePath(embeddedDep)}`; + } + } +} + +function applyPackageJsonOverriding( + pkgToCustomize: BackstagePackageJson, + overriding: CustomizeForDynamicUseOptions['overriding'], +): void { + if (!overriding) { + return; + } + for (const field of Object.keys(overriding)) { + if (!Object.hasOwn(overriding, field)) { + continue; + } + (pkgToCustomize as any)[field] = (overriding as any)[field]; + } +} + +function stripDistDynamicEntriesFromFiles( + pkgToCustomize: BackstagePackageJson, +) { + pkgToCustomize.files = pkgToCustomize.files?.filter( + f => !f.startsWith('dist-dynamic/'), + ); +} + +/** @aws-sdk/util-utf8-browser workaround — see https://github.com/aws/aws-sdk-js-v3/issues/5305 */ +function mergeOverridesForDynamicPackage( + pkgToCustomize: BackstagePackageJson, + additionalOverrides: CustomizeForDynamicUseOptions['additionalOverrides'], +): void { + const existing = (pkgToCustomize as any).overrides || {}; + const merged: Record = { + '@aws-sdk/util-utf8-browser': { + '@smithy/util-utf8': '^2.0.0', + }, + ...existing, + }; + if (additionalOverrides) { + Object.assign(merged, additionalOverrides); + } + (pkgToCustomize as any).overrides = merged; +} + +/** Merge order: AWS workaround, packed manifest, workspace, additional (backend embed wins on conflicts). */ +function mergeResolutionsForDynamicPackage( + pkgToCustomize: BackstagePackageJson, + workspaceResolutions: CustomizeForDynamicUseOptions['workspaceResolutions'], + additionalResolutions: CustomizeForDynamicUseOptions['additionalResolutions'], +): void { + const existing = (pkgToCustomize as any).resolutions || {}; + const merged: Record = { + '@aws-sdk/util-utf8-browser': 'npm:@smithy/util-utf8@~2', + ...existing, + }; + if (workspaceResolutions) { + Object.assign(merged, workspaceResolutions); + } + if (additionalResolutions) { + Object.assign(merged, additionalResolutions); + } + (pkgToCustomize as any).resolutions = merged; +} + +export function customizeForDynamicUse( + options: CustomizeForDynamicUseOptions, +): (dynamicPkgPath: string) => Promise { + return async (dynamicPkgPath: string): Promise => { + const dynamicPkgContent = await fs.readFile(dynamicPkgPath, 'utf8'); + const pkgToCustomize = JSON.parse( + dynamicPkgContent, + ) as BackstagePackageJson; + + applyPackageJsonOverriding(pkgToCustomize, options.overriding); + stripDistDynamicEntriesFromFiles(pkgToCustomize); + + if (pkgToCustomize.dependencies) { + for (const dep of Object.keys(pkgToCustomize.dependencies)) { + processDependencyForDynamicUse(dep, pkgToCustomize, options); + } + } + + // 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 = {}; + + mergeOverridesForDynamicPackage( + pkgToCustomize, + options.additionalOverrides, + ); + mergeResolutionsForDynamicPackage( + pkgToCustomize, + options.workspaceResolutions, + options.additionalResolutions, + ); + + options.after?.(pkgToCustomize); + + await fs.writeJson(dynamicPkgPath, pkgToCustomize, { + encoding: 'utf8', + spaces: 2, + }); + }; +} + +const YARN_RC_FILENAME = '.yarnrc.yml'; + +/** Milliseconds; standalone `dist-dynamic` installs can be slow on large lockfiles. */ +const YARN_RC_EXPORT_HTTP_TIMEOUT = 300_000; + +/** Extract Yarn release semver from a Berry `yarnPath` value (e.g. `.yarn/releases/yarn-4.8.1.cjs`). */ +export function parseYarnVersionFromYarnPath( + yarnPath: unknown, +): string | undefined { + if (typeof yarnPath !== 'string') { + return undefined; + } + const normalized = yarnPath.replaceAll('\\', '/'); + const m = /yarn-([^/]+)\.cjs$/.exec(normalized); + return m?.[1]; +} + +/** Same discovery order as `initializeYarnProject` lockfile copy: plugin dir, then monorepo root. */ +export async function resolveYarnLockSource( + pluginPkgDir: string, + monorepoRoot: string, +): Promise { + const localLock = path.join(pluginPkgDir, 'yarn.lock'); + if (await fs.pathExists(localLock)) { + return localLock; + } + const rootLock = path.join(monorepoRoot, 'yarn.lock'); + if (await fs.pathExists(rootLock)) { + return rootLock; + } + return undefined; +} + +/** True when a resolution value cannot be reused in standalone dist-dynamic (monorepo protocols, nested shapes). */ +export function shouldOmitWorkspaceResolutionValue(value: unknown): boolean { + if (value === null) { + return false; + } + if (typeof value === 'object') { + return true; + } + if (typeof value !== 'string') { + return false; + } + return /^\s*(workspace|portal|link):/i.test(value); +} + +/** + * Drops resolution entries that are not portable to a standalone Yarn project under dist-dynamic. + * Returns keys that were omitted (for logging). + */ +export function filterWorkspaceResolutionsForDynamicExport( + raw: Record, +): { kept: Record; omittedKeys: string[] } { + const kept: Record = {}; + const omittedKeys: string[] = []; + for (const key of Object.keys(raw)) { + if (!Object.hasOwn(raw, key)) { + continue; + } + const val = raw[key]; + if (shouldOmitWorkspaceResolutionValue(val)) { + omittedKeys.push(key); + continue; + } + kept[key] = val; + } + return { kept, omittedKeys }; +} + +/** + * Reads `resolutions` from the package.json next to the same yarn.lock used for export + * (plugin package or monorepo root). Non-portable entries are omitted with a single Task.log warning. + */ +export async function loadResolutionsFromYarnLockWorkspace({ + pluginPkgDir, + monorepoRoot, +}: { + pluginPkgDir: string; + monorepoRoot: string; +}): Promise> { + const lockPath = await resolveYarnLockSource(pluginPkgDir, monorepoRoot); + if (!lockPath) { + return {}; + } + const pkgJsonPath = path.join(path.dirname(lockPath), 'package.json'); + if (!(await fs.pathExists(pkgJsonPath))) { + return {}; + } + let pkg: unknown; + try { + pkg = await fs.readJson(pkgJsonPath); + } catch { + return {}; + } + if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) { + return {}; + } + const resolutions = (pkg as { resolutions?: unknown }).resolutions; + if ( + !resolutions || + typeof resolutions !== 'object' || + Array.isArray(resolutions) + ) { + return {}; + } + const { kept, omittedKeys } = filterWorkspaceResolutionsForDynamicExport( + resolutions as Record, + ); + if (omittedKeys.length > 0) { + Task.log( + chalk.yellow( + `Omitted ${omittedKeys.length} workspace ${chalk.cyan( + 'resolutions', + )} from lockfile-adjacent ${chalk.cyan( + 'package.json', + )} (not portable to dist-dynamic): ${chalk.cyan( + [...omittedKeys] + .sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }), + ) + .join(', '), + )}`, + ), + ); + } + return kept; +} + +async function resolveYarnRcSource( + pluginPkgDir: string, + monorepoRoot: string, +): Promise { + const localRc = path.join(pluginPkgDir, YARN_RC_FILENAME); + if (await fs.pathExists(localRc)) { + return localRc; + } + const rootRc = path.join(monorepoRoot, YARN_RC_FILENAME); + if (await fs.pathExists(rootRc)) { + return rootRc; + } + return undefined; +} + +function yarnSemverForPackageManager( + yarnPathSemver: string | undefined, + yarnVersion: string, +): string { + return yarnPathSemver ?? yarnVersion; +} + +/** + * Dynamic plugin exports run `yarn install` in dist-dynamic; Yarn Berry must use the + * node-modules linker there (not PnP / other layouts from the monorepo). + */ +async function enforceBerryNodeModulesLinker( + yarnRcDest: string, + yarnVersion: string, +): Promise { + if (yarnVersion.startsWith('1.')) { + return; + } + + if (!(await fs.pathExists(yarnRcDest))) { + await writeBerryStandaloneYarnRc(yarnRcDest); + return; + } + + const raw = await fs.readFile(yarnRcDest, 'utf8'); + const doc = YAML.parse(raw); + if (doc && typeof doc === 'object' && !Array.isArray(doc)) { + const mapping = doc as Record; + if (mapping.nodeLinker === 'node-modules') { + return; + } + mapping.nodeLinker = 'node-modules'; + await fs.writeFile( + yarnRcDest, + `${YAML.stringify(mapping).trimEnd()}\n`, + 'utf8', + ); + return; + } + + await writeBerryStandaloneYarnRc(yarnRcDest); +} + +async function copyYarnLockWhenNeeded( + copyYarnLockIfMissing: boolean, + yarnLockDest: string, + pluginPkgDir: string, + monorepoRoot: string, +): Promise { + if (!copyYarnLockIfMissing || (await fs.pathExists(yarnLockDest))) { + return; + } + const lockSource = await resolveYarnLockSource(pluginPkgDir, monorepoRoot); + if (!lockSource) { + 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( + monorepoRoot, + )})`, + ); + } + await fs.copyFile(lockSource, yarnLockDest); +} + +function yarnPathSemverFromYamlDoc(doc: unknown): string | undefined { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + return undefined; + } + return parseYarnVersionFromYarnPath( + (doc as Record).yarnPath, + ); +} + +async function tryYarnPathSemverFromRcPath( + rcPath: string, +): Promise { + if (!(await fs.pathExists(rcPath))) { + return undefined; + } + try { + const raw = await fs.readFile(rcPath, 'utf8'); + return yarnPathSemverFromYamlDoc(YAML.parse(raw)); + } catch { + return undefined; + } +} + +/** + * Semver for `package.json#packageManager` only (read-only). Does not copy workspace `.yarnrc.yml`. + */ +async function yarnPathSemverForExportPackageManager( + yarnRcDest: string, + pluginPkgDir: string, + monorepoRoot: string, + yarnVersion: string, +): Promise { + if (yarnVersion.startsWith('1.')) { + return undefined; + } + const fromExportTree = await tryYarnPathSemverFromRcPath(yarnRcDest); + if (fromExportTree) { + return fromExportTree; + } + const rcSource = await resolveYarnRcSource(pluginPkgDir, monorepoRoot); + if (!rcSource) { + return undefined; + } + return tryYarnPathSemverFromRcPath(rcSource); +} + +/** Minimal Berry config for standalone `dist-dynamic` (no monorepo plugins / `.yarn/plugins`). */ +async function writeBerryStandaloneYarnRc(yarnRcDest: string): Promise { + await fs.writeFile( + yarnRcDest, + `${YAML.stringify({ + httpTimeout: YARN_RC_EXPORT_HTTP_TIMEOUT, + nodeLinker: 'node-modules', + }).trimEnd()}\n`, + 'utf8', + ); +} + +/** + * Prepares a standalone Yarn project layout under `exportDir`: optional lockfile copy, + * generated minimal `.yarnrc.yml` for Yarn Berry, `nodeLinker: node-modules`, and `packageManager` in package.json. + */ +export async function initializeYarnProject({ + pluginPkgDir, + monorepoRoot, + exportDir, + yarnVersion, + copyYarnLockIfMissing, +}: { + pluginPkgDir: string; + monorepoRoot: string; + exportDir: string; + yarnVersion: string; + copyYarnLockIfMissing: boolean; +}): Promise { + const yarnLockDest = path.join(exportDir, 'yarn.lock'); + const yarnRcDest = path.join(exportDir, YARN_RC_FILENAME); + + await copyYarnLockWhenNeeded( + copyYarnLockIfMissing, + yarnLockDest, + pluginPkgDir, + monorepoRoot, + ); + + const yarnPathSemver = await yarnPathSemverForExportPackageManager( + yarnRcDest, + pluginPkgDir, + monorepoRoot, + yarnVersion, + ); + + if (!yarnVersion.startsWith('1.')) { + await writeBerryStandaloneYarnRc(yarnRcDest); + } + + await enforceBerryNodeModulesLinker(yarnRcDest, yarnVersion); + + const packageJsonPath = path.join(exportDir, 'package.json'); + const pkgJson = await fs.readJson(packageJsonPath); + pkgJson.packageManager = `yarn@${yarnSemverForPackageManager( + yarnPathSemver, + yarnVersion, + )}`; + await fs.writeJson(packageJsonPath, pkgJson, { + encoding: 'utf8', + spaces: 2, + }); +} diff --git a/src/commands/export-dynamic-plugin/frontend.ts b/src/commands/export-dynamic-plugin/frontend.ts index f17efc0..52cbcaa 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,31 @@ * 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 os from 'node:os'; 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, + initializeYarnProject, + loadResolutionsFromYarnLockWorkspace, +} 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 +52,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'), )}`, ); @@ -82,7 +74,15 @@ export async function frontend( path.join(target, '.gitignore'), ` * -`, +${ + opts.trackDynamicManifestAndLockFile + ? ` +!package.json +!yarn.lock +!.yarnrc.yml +` + : '' +}`, ); await productionPack({ @@ -90,11 +90,13 @@ export async function frontend( 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 +105,197 @@ export async function frontend( ) { files.push('dist-scalprum'); } + const monoRepoPackages = await getPackages(paths.targetDir); + const workspaceResolutions = await loadResolutionsFromYarnLockWorkspace({ + pluginPkgDir: paths.targetDir, + monorepoRoot: paths.targetRoot, + }); await customizeForDynamicUse({ embedded: [], isYarnV1: false, monoRepoPackages, - overridding: { + workspaceResolutions, + 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.`, - ); - } + 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; + } - await fs.remove(resolvedScalprumDistPath); + const defaultScalprum = { + name: scalprumName, + exposedModules: { + PluginRoot: './src/index.ts', + }, + }; - await buildScalprumPlugin({ - writeStats: false, - configPaths: [], - targetDir: paths.targetDir, - pluginMetadata: { - ...scalprum, - version, - }, + 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, + }); +} + +function buildFrontendYarnInstallCommand( + yarn: string, + yarnVersion: string, + yarnLockExists: boolean, + redirect: string, +): string { + if (yarnVersion.startsWith('1.')) { + const lockFlag = yarnLockExists ? ' --frozen-lockfile' : ''; + return `${yarn} install --production${lockFlag} ${redirect}`; } + const lockFlag = yarnLockExists ? ' --immutable' : ' --no-immutable'; + return `${yarn} install${lockFlag} ${redirect}`; +} - return target; +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); + + await initializeYarnProject({ + pluginPkgDir: paths.targetDir, + monorepoRoot: paths.targetRoot, + exportDir: target, + yarnVersion, + copyYarnLockIfMissing: !yarnLockExists, + }); + + 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; + } + + const actionVerb = yarnLockExists ? 'Verifying' : 'Creating'; + Task.log(`${actionVerb} filtered yarn.lock file for the exported package`); + + const logFile = path.join(os.tmpdir(), 'rhdh-cli.yarn-install.log'); + const redirect = `> ${logFile}`; + const yarnInstall = buildFrontendYarnInstallCommand( + yarn, + yarnVersion, + yarnLockExists, + 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(path.join(target, 'node_modules')); + await fs.remove(path.join(target, '.yarn')); + await fs.remove(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; +}; diff --git a/src/commands/export-dynamic-plugin/workspace-resolutions.test.ts b/src/commands/export-dynamic-plugin/workspace-resolutions.test.ts new file mode 100644 index 0000000..4c85dcf --- /dev/null +++ b/src/commands/export-dynamic-plugin/workspace-resolutions.test.ts @@ -0,0 +1,110 @@ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import { + customizeForDynamicUse, + filterWorkspaceResolutionsForDynamicExport, + shouldOmitWorkspaceResolutionValue, +} from './common-utils'; + +describe('shouldOmitWorkspaceResolutionValue', () => { + it('omits monorepo protocol strings and nested values', () => { + expect(shouldOmitWorkspaceResolutionValue('workspace:*')).toBe(true); + expect(shouldOmitWorkspaceResolutionValue(' portal:../x')).toBe(true); + expect(shouldOmitWorkspaceResolutionValue('link:./foo')).toBe(true); + expect(shouldOmitWorkspaceResolutionValue({ a: 1 })).toBe(true); + expect(shouldOmitWorkspaceResolutionValue([1])).toBe(true); + }); + + it('keeps semver, npm, patch, file, and scalars', () => { + expect(shouldOmitWorkspaceResolutionValue('1.2.3')).toBe(false); + expect(shouldOmitWorkspaceResolutionValue('npm:foo@1')).toBe(false); + expect(shouldOmitWorkspaceResolutionValue('patch:pkg@1.0.0')).toBe(false); + expect(shouldOmitWorkspaceResolutionValue('file:./vendor/pkg')).toBe(false); + expect(shouldOmitWorkspaceResolutionValue(1)).toBe(false); + expect(shouldOmitWorkspaceResolutionValue(null)).toBe(false); + }); +}); + +describe('filterWorkspaceResolutionsForDynamicExport', () => { + it('partitions portable vs omitted keys', () => { + const { kept, omittedKeys } = filterWorkspaceResolutionsForDynamicExport({ + a: 'workspace:*', + b: '1.0.0', + c: 'portal:../foo', + d: { nested: true }, + }); + expect(kept).toEqual({ b: '1.0.0' }); + expect( + [...omittedKeys].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: 'base' }), + ), + ).toEqual(['a', 'c', 'd']); + }); +}); + +describe('customizeForDynamicUse resolutions merge', () => { + it('merges workspace after pack and additionalResolutions wins on conflict', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rhdh-cli-wsres-')); + const pkgPath = path.join(dir, 'package.json'); + await fs.writeJson( + pkgPath, + { + name: 'test-pkg', + version: '1.0.0', + dependencies: {}, + resolutions: { foo: 'from-pack', bar: 'from-pack-bar' }, + }, + { spaces: 2 }, + ); + + const run = customizeForDynamicUse({ + embedded: [], + isYarnV1: false, + workspaceResolutions: { foo: 'from-workspace', baz: 'from-ws-baz' }, + additionalResolutions: { foo: 'from-additional' }, + }); + await run(pkgPath); + + const out = await fs.readJson(pkgPath); + expect(out.resolutions.foo).toBe('from-additional'); + expect(out.resolutions.bar).toBe('from-pack-bar'); + expect(out.resolutions.baz).toBe('from-ws-baz'); + expect(out.resolutions['@aws-sdk/util-utf8-browser']).toBe( + 'npm:@smithy/util-utf8@~2', + ); + + await fs.remove(dir); + }); + + it('merges workspace resolutions without a direct dependency (transitive / CVE pins)', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rhdh-cli-wsres2-')); + const pkgPath = path.join(dir, 'package.json'); + await fs.writeJson( + pkgPath, + { + name: 'backend-only', + version: '1.0.0', + dependencies: { 'my-backend-lib': '1' }, + }, + { spaces: 2 }, + ); + + const run = customizeForDynamicUse({ + embedded: [], + isYarnV1: false, + workspaceResolutions: { + react: '^18', + 'my-backend-lib': 'patch:foo', + }, + }); + await run(pkgPath); + + const out = await fs.readJson(pkgPath); + expect(out.resolutions.react).toBe('^18'); + expect(out.resolutions['my-backend-lib']).toBe('patch:foo'); + + await fs.remove(dir); + }); +}); diff --git a/src/commands/index.ts b/src/commands/index.ts index 978160d..f42f4e6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -30,7 +30,6 @@ export function registerPluginCommand(program: Command) { .description( 'Build and export a plugin package to be loaded as a dynamic plugin. The repackaged dynamic plugin is exported inside a ./dist-dynamic sub-folder.', ) - .option('--minify', 'Minify the generated code (backend plugin only).') .option( '--embed-package [package-name...]', 'Optional list of packages that should be embedded inside the generated code of a backend dynamic plugin, removed from the plugin dependencies, while their direct dependencies will be hoisted to the dynamic plugin dependencies (backend plugin only).', @@ -53,7 +52,7 @@ export function registerPluginCommand(program: Command) { ) .option( '--no-install', - 'Do not run `yarn install` to fill the dynamic plugin `node_modules` folder (backend plugin only).', + 'Do not run `yarn install` in `dist-dynamic` to fill `node_modules` (backend and frontend plugin exports).', ) .option( '--no-build', @@ -65,7 +64,7 @@ export function registerPluginCommand(program: Command) { ) .option( '--dev', - 'Allow testing/debugging a dynamic plugin locally. This creates a link from the dynamic plugin content to the plugin package `src` folder, to enable the use of source maps (backend plugin only). This also installs the dynamic plugin content (symlink) into the dynamic plugins root folder configured in the app config (or copies the plugin content to the location explicitely provided by the `--dynamic-plugins-root` argument).', + 'Allow testing/debugging a dynamic plugin locally. For Node (backend) plugins, symlinks `src` into `dist-dynamic` for source maps. Also symlinks or copies `dist-dynamic` into the dynamic plugins root from app config (`dynamicPlugins.rootDirectory`) or into `--dynamic-plugins-root` when set.', ) .option( '--dynamic-plugins-root ', @@ -77,7 +76,7 @@ export function registerPluginCommand(program: Command) { ) .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.', + 'When set, `dist-dynamic/.gitignore` allows committing `package.json`, `yarn.lock`, and `.yarnrc.yml` from `dist-dynamic` (otherwise that folder is entirely ignored). The flag name mentions manifest and lockfile only; `.yarnrc.yml` is included so the generated minimal Yarn Berry config can be tracked with them. Applies to backend and frontend plugin exports.', false, ) .option( diff --git a/yarn.lock b/yarn.lock index 0dd4aa0..7ef2c94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3214,17 +3214,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.1 - resolution: "@eslint-community/eslint-utils@npm:4.9.1" - dependencies: - eslint-visitor-keys: ^3.4.3 - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 0a27c2d676c4be6b329ebb5dd8f6c5ef5fae9a019ff575655306d72874bb26f3ab20e0b241a5f086464bb1f2511ca26a29ff6f80c1e2b0b02eca4686b4dfe1b5 - languageName: node - linkType: hard - "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.1 resolution: "@eslint-community/regexpp@npm:4.12.1" @@ -3232,42 +3221,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1": - version: 4.12.2 - resolution: "@eslint-community/regexpp@npm:4.12.2" - checksum: 1770bc81f676a72f65c7200b5675ff7a349786521f30e66125faaf767fde1ba1c19c3790e16ba8508a62a3933afcfc806a893858b3b5906faf693d862b9e4120 - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.21.1": - version: 0.21.1 - resolution: "@eslint/config-array@npm:0.21.1" - dependencies: - "@eslint/object-schema": ^2.1.7 - debug: ^4.3.1 - minimatch: ^3.1.2 - checksum: fc5b57803b059f7c1f62950ef83baf045a01887fc00551f9e87ac119246fcc6d71c854a7f678accc79cbf829ed010e8135c755a154b0f54b129c538950cd7e6a - languageName: node - linkType: hard - -"@eslint/config-helpers@npm:^0.4.2": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" - dependencies: - "@eslint/core": ^0.17.0 - checksum: 63ff6a0730c9fff2edb80c89b39b15b28d6a635a1c3f32cf0d7eb3e2625f2efbc373c5531ae84e420ae36d6e37016dd40c365b6e5dee6938478e9907aaadae0b - languageName: node - linkType: hard - -"@eslint/core@npm:^0.17.0": - version: 0.17.0 - resolution: "@eslint/core@npm:0.17.0" - dependencies: - "@types/json-schema": ^7.0.15 - checksum: ff9b5b4987f0bae4f2a4cfcdc7ae584ad3b0cb58526ca562fb281d6837700a04c7f3c86862e95126462318f33f60bf38e1cb07ed0e2449532d4b91cd5f4ab1f2 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -3285,23 +3238,6 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.1": - version: 3.3.3 - resolution: "@eslint/eslintrc@npm:3.3.3" - dependencies: - ajv: ^6.12.4 - debug: ^4.3.2 - espree: ^10.0.1 - globals: ^14.0.0 - ignore: ^5.2.0 - import-fresh: ^3.2.1 - js-yaml: ^4.1.1 - minimatch: ^3.1.2 - strip-json-comments: ^3.1.1 - checksum: d1e16e47f1bb29af32defa597eaf84ac0ff8c06760c0a5f4933c604cd9d931d48c89bed96252222f22abac231898a53bc41385a5e6129257f0060b5ec431bdb2 - languageName: node - linkType: hard - "@eslint/js@npm:8.57.1": version: 8.57.1 resolution: "@eslint/js@npm:8.57.1" @@ -3309,47 +3245,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.39.2": - version: 9.39.2 - resolution: "@eslint/js@npm:9.39.2" - checksum: 362aa447266fa5717e762b2b252f177345cb0d7b2954113db9773b3a28898f7cbbc807e07f8078995e6da3f62791f7c5fa2c03517b7170a8e76613cf7fd83c92 - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^2.1.7": - version: 2.1.7 - resolution: "@eslint/object-schema@npm:2.1.7" - checksum: fc5708f192476956544def13455d60fd1bafbf8f062d1e05ec5c06dd470b02078eaf721e696a8b31c1c45d2056723a514b941ae5eea1398cc7e38eba6711a775 - languageName: node - linkType: hard - -"@eslint/plugin-kit@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/plugin-kit@npm:0.4.1" - dependencies: - "@eslint/core": ^0.17.0 - levn: ^0.4.1 - checksum: 3f4492e02a3620e05d46126c5cfeff5f651ecf33466c8f88efb4812ae69db5f005e8c13373afabc070ecca7becd319b656d6670ad5093f05ca63c2a8841d99ba - languageName: node - linkType: hard - -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 611e0545146f55ddfdd5c20239cfb7911f9d0e28258787c4fc1a1f6214250830c9367aaaeace0096ed90b6739bee1e9c52ad5ba8adaf74ab8b449119303babfe - languageName: node - linkType: hard - -"@humanfs/node@npm:^0.16.6": - version: 0.16.7 - resolution: "@humanfs/node@npm:0.16.7" - dependencies: - "@humanfs/core": ^0.19.1 - "@humanwhocodes/retry": ^0.4.0 - checksum: 7d2a396a94d80158ce320c0fd7df9aebb82edb8b667e5aaf8f87f4ca50518d0941ca494e0cd68e06b061e777ce5f7d26c45f93ac3fa9f7b11fd1ff26e3cd1440 - languageName: node - linkType: hard - "@humanwhocodes/config-array@npm:^0.13.0": version: 0.13.0 resolution: "@humanwhocodes/config-array@npm:0.13.0" @@ -3375,13 +3270,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": - version: 0.4.3 - resolution: "@humanwhocodes/retry@npm:0.4.3" - checksum: d423455b9d53cf01f778603404512a4246fb19b83e74fe3e28c70d9a80e9d4ae147d2411628907ca983e91a855a52535859a8bb218050bc3f6dbd7a553b7b442 - languageName: node - linkType: hard - "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -5097,6 +4985,8 @@ __metadata: "@manypkg/get-packages": ^1.1.3 "@openshift/dynamic-plugin-sdk-webpack": ^3.0.0 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.7 + "@spotify/eslint-config-base": 15.0.0 + "@spotify/eslint-config-typescript": 15.0.0 "@spotify/prettier-config": 15.0.0 "@svgr/webpack": ^6.5.1 "@types/fs-extra": 9.0.13 @@ -5107,6 +4997,8 @@ __metadata: "@types/recursive-readdir": 2.2.4 "@types/tar": ^6.1.1 "@types/yarnpkg__lockfile": 1.1.9 + "@typescript-eslint/eslint-plugin": ^8.17.0 + "@typescript-eslint/parser": ^8.16.0 "@yarnpkg/lockfile": ^1.1.0 "@yarnpkg/parsers": ^3.0.0-rc.4 axios: ^1.9.0 @@ -5118,8 +5010,12 @@ __metadata: css-loader: ^6.5.1 esbuild: ^0.25.0 esbuild-loader: ^2.18.0 - eslint: ^9.26.0 + eslint: ^8.57.0 eslint-config-prettier: ^8.10.0 + eslint-plugin-deprecation: ^3.0.0 + eslint-plugin-import: ^2.31.0 + eslint-plugin-jest: ^28.9.0 + eslint-plugin-unused-imports: ^4.1.4 eslint-webpack-plugin: ^3.2.0 fork-ts-checker-webpack-plugin: ^7.0.0-alpha.8 fs-extra: ^10.1.0 @@ -5703,7 +5599,7 @@ __metadata: languageName: node linkType: hard -"@spotify/eslint-config-base@npm:^15.0.0": +"@spotify/eslint-config-base@npm:15.0.0, @spotify/eslint-config-base@npm:^15.0.0": version: 15.0.0 resolution: "@spotify/eslint-config-base@npm:15.0.0" peerDependencies: @@ -5724,7 +5620,7 @@ __metadata: languageName: node linkType: hard -"@spotify/eslint-config-typescript@npm:^15.0.0": +"@spotify/eslint-config-typescript@npm:15.0.0, @spotify/eslint-config-typescript@npm:^15.0.0": version: 15.0.0 resolution: "@spotify/eslint-config-typescript@npm:15.0.0" peerDependencies: @@ -6536,7 +6432,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": +"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.8": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" checksum: bd93e2e415b6f182ec4da1074e1f36c480f1d26add3e696d54fb30c09bc470897e41361c8fd957bf0985024f8fbf1e6e2aff977d79352ef7eb93a5c6dcff6c11 @@ -10941,16 +10837,6 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" - dependencies: - esrecurse: ^4.3.0 - estraverse: ^5.2.0 - checksum: cf88f42cd5e81490d549dc6d350fe01e6fe420f9d9ea34f134bb359b030e3c4ef888d36667632e448937fe52449f7181501df48c08200e3d3b0fee250d05364e - languageName: node - linkType: hard - "eslint-visitor-keys@npm:^3.4.1, eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" @@ -10965,13 +10851,6 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 3a77e3f99a49109f6fb2c5b7784bc78f9743b834d238cdba4d66c602c6b52f19ed7bcd0a5c5dbbeae3a8689fd785e76c001799f53d2228b278282cf9f699fff5 - languageName: node - linkType: hard - "eslint-webpack-plugin@npm:^3.2.0": version: 3.2.0 resolution: "eslint-webpack-plugin@npm:3.2.0" @@ -10988,7 +10867,7 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^8.6.0": +"eslint@npm:^8.57.0, eslint@npm:^8.6.0": version: 8.57.1 resolution: "eslint@npm:8.57.1" dependencies: @@ -11036,55 +10915,6 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.26.0": - version: 9.39.2 - resolution: "eslint@npm:9.39.2" - dependencies: - "@eslint-community/eslint-utils": ^4.8.0 - "@eslint-community/regexpp": ^4.12.1 - "@eslint/config-array": ^0.21.1 - "@eslint/config-helpers": ^0.4.2 - "@eslint/core": ^0.17.0 - "@eslint/eslintrc": ^3.3.1 - "@eslint/js": 9.39.2 - "@eslint/plugin-kit": ^0.4.1 - "@humanfs/node": ^0.16.6 - "@humanwhocodes/module-importer": ^1.0.1 - "@humanwhocodes/retry": ^0.4.2 - "@types/estree": ^1.0.6 - ajv: ^6.12.4 - chalk: ^4.0.0 - cross-spawn: ^7.0.6 - debug: ^4.3.2 - escape-string-regexp: ^4.0.0 - eslint-scope: ^8.4.0 - eslint-visitor-keys: ^4.2.1 - espree: ^10.4.0 - esquery: ^1.5.0 - esutils: ^2.0.2 - fast-deep-equal: ^3.1.3 - file-entry-cache: ^8.0.0 - find-up: ^5.0.0 - glob-parent: ^6.0.2 - ignore: ^5.2.0 - imurmurhash: ^0.1.4 - is-glob: ^4.0.0 - json-stable-stringify-without-jsonify: ^1.0.1 - lodash.merge: ^4.6.2 - minimatch: ^3.1.2 - natural-compare: ^1.4.0 - optionator: ^0.9.3 - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: bfa288fe6b19b6e7f8868e1434d8e469603203d6259e4451b8be4e2172de3172f3b07ed8943ba3904f3545c7c546062c0d656774baa0a10a54483f3907c525e3 - languageName: node - linkType: hard - "esm@npm:^3.2.25": version: 3.2.25 resolution: "esm@npm:3.2.25" @@ -11092,17 +10922,6 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" - dependencies: - acorn: ^8.15.0 - acorn-jsx: ^5.3.2 - eslint-visitor-keys: ^4.2.1 - checksum: 5f9d0d7c81c1bca4bfd29a55270067ff9d575adb8c729a5d7f779c2c7b910bfc68ccf8ec19b29844b707440fc159a83868f22c8e87bbf7cbcb225ed067df6c85 - languageName: node - linkType: hard - "espree@npm:^9.6.0, espree@npm:^9.6.1": version: 9.6.1 resolution: "espree@npm:9.6.1" @@ -11143,15 +10962,6 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.5.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: ^5.1.0 - checksum: 3239792b68cf39fe18966d0ca01549bb15556734f0144308fd213739b0f153671ae916013fce0bca032044a4dbcda98b43c1c667f20c20a54dec3597ac0d7c27 - languageName: node - linkType: hard - "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -11579,15 +11389,6 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^8.0.0": - version: 8.0.0 - resolution: "file-entry-cache@npm:8.0.0" - dependencies: - flat-cache: ^4.0.0 - checksum: f67802d3334809048c69b3d458f672e1b6d26daefda701761c81f203b80149c35dea04d78ea4238969dd617678e530876722a0634c43031a0957f10cc3ed190f - languageName: node - linkType: hard - "file-type@npm:20.5.0": version: 20.5.0 resolution: "file-type@npm:20.5.0" @@ -11722,16 +11523,6 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^4.0.0": - version: 4.0.1 - resolution: "flat-cache@npm:4.0.1" - dependencies: - flatted: ^3.2.9 - keyv: ^4.5.4 - checksum: 899fc86bf6df093547d76e7bfaeb900824b869d7d457d02e9b8aae24836f0a99fbad79328cfd6415ee8908f180699bf259dc7614f793447cb14f707caf5996f6 - languageName: node - linkType: hard - "flatted@npm:^3.2.7, flatted@npm:^3.2.9": version: 3.4.2 resolution: "flatted@npm:3.4.2" @@ -12316,13 +12107,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 534b8216736a5425737f59f6e6a5c7f386254560c9f41d24a9227d60ee3ad4a9e82c5b85def0e212e9d92162f83a92544be4c7fd4c902cb913736c10e08237ac - languageName: node - linkType: hard - "globalthis@npm:^1.0.1, globalthis@npm:^1.0.3, globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -14393,17 +14177,6 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" - dependencies: - argparse: ^2.0.1 - bin: - js-yaml: bin/js-yaml.js - checksum: ea2339c6930fe048ec31b007b3c90be2714ab3e7defcc2c27ebf30c74fd940358f29070b4345af0019ef151875bf3bc3f8644bea1bab0372652b5044813ac02d - languageName: node - linkType: hard - "js-yaml@npm:~3.13.1": version: 3.13.1 resolution: "js-yaml@npm:3.13.1" @@ -14728,7 +14501,7 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^4.5.3, keyv@npm:^4.5.4": +"keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" dependencies: