diff --git a/.changeset/actions-service-plugin-id.md b/.changeset/actions-service-plugin-id.md new file mode 100644 index 0000000000..3fc86db8d8 --- /dev/null +++ b/.changeset/actions-service-plugin-id.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +Added `pluginId` field to `ActionsServiceAction` type, populated from the registering plugin's metadata. diff --git a/.changeset/add-cli-plugin-role.md b/.changeset/add-cli-plugin-role.md new file mode 100644 index 0000000000..1f09a08ade --- /dev/null +++ b/.changeset/add-cli-plugin-role.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Added a new `cli-module` package role for packages that provide CLI plugin extensions. diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md new file mode 100644 index 0000000000..987f02c6d9 --- /dev/null +++ b/.changeset/add-frontend-dev-utils.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-dev-utils': minor +--- + +Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. The options interface accepts `features` together with route bindings through `bindRoutes`. diff --git a/.changeset/app-visualizer-use-frontend-dev-utils.md b/.changeset/app-visualizer-use-frontend-dev-utils.md new file mode 100644 index 0000000000..f047fe8f7e --- /dev/null +++ b/.changeset/app-visualizer-use-frontend-dev-utils.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-visualizer': patch +--- + +Switched dev entry point to use `createDevApp` from `@backstage/frontend-dev-utils`. diff --git a/.changeset/auth-migrate-to-bui.md b/.changeset/auth-migrate-to-bui.md new file mode 100644 index 0000000000..974fe5abee --- /dev/null +++ b/.changeset/auth-migrate-to-bui.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth': patch +--- + +Migrated the ConsentPage UI from Material-UI and `@backstage/core-components` to `@backstage/ui`. diff --git a/.changeset/auth-provider-icon-element-core-components.md b/.changeset/auth-provider-icon-element-core-components.md new file mode 100644 index 0000000000..7bd7aa6d52 --- /dev/null +++ b/.changeset/auth-provider-icon-element-core-components.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +The login request dialog now handles auth provider icons passed as `IconElement` in addition to `IconComponent`. diff --git a/.changeset/auth-provider-icon-element-user-settings.md b/.changeset/auth-provider-icon-element-user-settings.md new file mode 100644 index 0000000000..16b9adb94f --- /dev/null +++ b/.changeset/auth-provider-icon-element-user-settings.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +The `ProviderSettingsItem` `icon` prop now accepts `IconElement` in addition to `IconComponent`. diff --git a/.changeset/auth-provider-icon-element.md b/.changeset/auth-provider-icon-element.md new file mode 100644 index 0000000000..def6666c2d --- /dev/null +++ b/.changeset/auth-provider-icon-element.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +The `icon` field on `AuthProviderInfo` now accepts `IconElement` in addition to `IconComponent`, letting you pass `` instead of `MyIcon`. diff --git a/.changeset/brave-pens-argue.md b/.changeset/brave-pens-argue.md new file mode 100644 index 0000000000..2ae66c29cf --- /dev/null +++ b/.changeset/brave-pens-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Fixed `resolvePackagePath` resolution for bundled dynamic plugins. When a plugin bundles its own copy of `@backstage/backend-plugin-api` inside `node_modules`, the `CommonJSModuleLoader` fallback now correctly resolves the plugin's `package.json` by name. Previously the fallback only applied when the resolution originated from the host application; it now also applies when originating from a bundled dependency, which is the case for plugins produced by the `backstage-cli package bundle` command. diff --git a/.changeset/bui-table-root-loading-prop.md b/.changeset/bui-table-root-loading-prop.md new file mode 100644 index 0000000000..7c3f453c82 --- /dev/null +++ b/.changeset/bui-table-root-loading-prop.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added a `loading` prop and `data-loading` data attribute to `TableRoot`, allowing consumers to distinguish between stale data and initial loading states. Both `stale` and `loading` set `aria-busy` on the table. + +Affected components: TableRoot diff --git a/.changeset/bui-table-skeleton-loading.md b/.changeset/bui-table-skeleton-loading.md new file mode 100644 index 0000000000..df20393c50 --- /dev/null +++ b/.changeset/bui-table-skeleton-loading.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Improved the `Table` component loading state to show a skeleton UI with visible headers instead of plain "Loading..." text. The table now renders its full structure during loading, with animated skeleton rows in place of data. The loading state includes proper accessibility support with `aria-busy` on the table and screen reader announcements. + +Affected components: Table diff --git a/.changeset/catalog-remove-entity-transaction.md b/.changeset/catalog-remove-entity-transaction.md new file mode 100644 index 0000000000..e8bd5f5c8f --- /dev/null +++ b/.changeset/catalog-remove-entity-transaction.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Improved catalog entity deletion so parent invalidation and deferred relation restitch scheduling are coordinated more safely. diff --git a/.changeset/cli-defaults-introduce.md b/.changeset/cli-defaults-introduce.md new file mode 100644 index 0000000000..b6bb175e62 --- /dev/null +++ b/.changeset/cli-defaults-introduce.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-defaults': minor +--- + +Introduced `@backstage/cli-defaults`, a convenience package that bundles all standard Backstage CLI modules. Install this single package as a `devDependency` to get the full default set of CLI commands without listing each module individually. diff --git a/.changeset/cli-discover-modules.md b/.changeset/cli-discover-modules.md new file mode 100644 index 0000000000..dac5f73f75 --- /dev/null +++ b/.changeset/cli-discover-modules.md @@ -0,0 +1,41 @@ +--- +'@backstage/cli': minor +--- + +The CLI now automatically discovers CLI modules from the project root's `dependencies` and `devDependencies`. Any installed package with the `cli-module` Backstage role will be loaded automatically without needing to be hardcoded in the CLI itself. + +If no CLI modules are found in the project dependencies, the CLI falls back to the built-in set of modules and prints a deprecation warning. This fallback will be removed in a future release. To prepare for this, add `@backstage/cli-defaults` as a `devDependency` in your root `package.json`: + +```json +{ + "devDependencies": { + "@backstage/cli-defaults": "backstage:^" + } +} +``` + +If you are not using the Backstage Yarn plugin, run the following instead: + +```sh +yarn workspace root add --dev @backstage/cli-defaults +``` + +For fine-grained control you can instead install individual CLI modules: + +```json +{ + "devDependencies": { + "@backstage/cli-module-auth": "backstage:^", + "@backstage/cli-module-build": "backstage:^", + "@backstage/cli-module-config": "backstage:^", + "@backstage/cli-module-github": "backstage:^", + "@backstage/cli-module-info": "backstage:^", + "@backstage/cli-module-lint": "backstage:^", + "@backstage/cli-module-maintenance": "backstage:^", + "@backstage/cli-module-migrate": "backstage:^", + "@backstage/cli-module-new": "backstage:^", + "@backstage/cli-module-test-jest": "backstage:^", + "@backstage/cli-module-translations": "backstage:^" + } +} +``` diff --git a/.changeset/cli-node-add-cli-plugin.md b/.changeset/cli-node-add-cli-plugin.md new file mode 100644 index 0000000000..2a6a5f20bc --- /dev/null +++ b/.changeset/cli-node-add-cli-plugin.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': minor +--- + +Added `createCliModule` API and related types for building Backstage CLI plugins. diff --git a/.changeset/cli-package-bundle.md b/.changeset/cli-package-bundle.md new file mode 100644 index 0000000000..602d631fd1 --- /dev/null +++ b/.changeset/cli-package-bundle.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-build': minor +--- + +Added `package bundle` command to create self-contained plugin bundles for dynamic loading, to be used by the `backend-dynamic-feature-service`. Supports backend and frontend plugins, with optional `--pre-packed-dir` for batch bundling from a pre-built workspace. diff --git a/.changeset/cli-support-cli-plugin-role.md b/.changeset/cli-support-cli-plugin-role.md new file mode 100644 index 0000000000..b336f7f551 --- /dev/null +++ b/.changeset/cli-support-cli-plugin-role.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added support for the new `cli-module` package role in the build system, ESLint configuration, Jest configuration, and maintenance commands. diff --git a/.changeset/cli-use-cli-plugin-api.md b/.changeset/cli-use-cli-plugin-api.md new file mode 100644 index 0000000000..c155483c10 --- /dev/null +++ b/.changeset/cli-use-cli-plugin-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Migrated CLI plugin modules to use `createCliModule` from `@backstage/cli-node`. diff --git a/.changeset/create-app-cli-modules.md b/.changeset/create-app-cli-modules.md new file mode 100644 index 0000000000..c1a8ca9a26 --- /dev/null +++ b/.changeset/create-app-cli-modules.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +The create-app templates now include `@backstage/cli-defaults` as a `devDependency`, enabling the CLI's automatic module discovery for newly created projects. diff --git a/.changeset/cruel-cities-jump.md b/.changeset/cruel-cities-jump.md new file mode 100644 index 0000000000..f37f8dcef1 --- /dev/null +++ b/.changeset/cruel-cities-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': patch +--- + +Added `removeFiles` helper function for staging file removals in Git. diff --git a/.changeset/devtools-cancel-scheduled-task.md b/.changeset/devtools-cancel-scheduled-task.md new file mode 100644 index 0000000000..fadd51f2aa --- /dev/null +++ b/.changeset/devtools-cancel-scheduled-task.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-devtools-common': patch +'@backstage/plugin-devtools': patch +--- + +Added `cancelScheduledTask` to the DevTools API and a cancel button to the scheduled tasks UI. diff --git a/.changeset/docs-frontend-plugin-installation.md b/.changeset/docs-frontend-plugin-installation.md new file mode 100644 index 0000000000..8a13f5c12d --- /dev/null +++ b/.changeset/docs-frontend-plugin-installation.md @@ -0,0 +1,16 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-unprocessed-entities': patch +'@backstage/plugin-devtools': patch +'@backstage/plugin-home': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-mui-to-bui': patch +'@backstage/plugin-org': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-user-settings': patch +--- + +Updated installation documentation to use feature discovery as the default. diff --git a/.changeset/document-table-cell-requirement.md b/.changeset/document-table-cell-requirement.md new file mode 100644 index 0000000000..fd99de519f --- /dev/null +++ b/.changeset/document-table-cell-requirement.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added documentation for the table cell wrapper requirement to TSDoc comments for `Cell`, `CellText`, `CellProfile`, `ColumnConfig`, and `RowRenderFn`. diff --git a/.changeset/few-clouds-pull.md b/.changeset/few-clouds-pull.md new file mode 100644 index 0000000000..4f8a67389b --- /dev/null +++ b/.changeset/few-clouds-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Added optional `returnWorkflowRunDetails` input to `github:actions:dispatch` action. When true, exposes `workflowRunId`, `workflowRunUrl`, and `workflowRunHtmlUrl` as outputs using the GitHub API `return_run_details` parameter. diff --git a/.changeset/fine-dragons-scream.md b/.changeset/fine-dragons-scream.md new file mode 100644 index 0000000000..b1373e25f3 --- /dev/null +++ b/.changeset/fine-dragons-scream.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added `SearchAutocomplete` and `SearchAutocompleteItem` components for building accessible search-with-results patterns. Built on React Aria's Autocomplete with keyboard navigation and screen reader support. Designed for async/external search results with a configurable popover width. + +**Affected components:** SearchAutocomplete, SearchAutocompleteItem diff --git a/.changeset/fix-dialog-height-overflow.md b/.changeset/fix-dialog-height-overflow.md new file mode 100644 index 0000000000..bf91d882dd --- /dev/null +++ b/.changeset/fix-dialog-height-overflow.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `Dialog` content overflowing when no `height` prop is set. The dialog now grows with its content and scrolls when content exceeds the viewport height. + +**Affected components**: Dialog diff --git a/.changeset/fix-header-container-padding.md b/.changeset/fix-header-container-padding.md new file mode 100644 index 0000000000..cfb3611b8c --- /dev/null +++ b/.changeset/fix-header-container-padding.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed incorrect bottom spacing caused by `Container` using `padding-bottom` for its default bottom spacing. Changed to `margin-bottom` and prevented it from applying when `Container` is used as the `Header` root element. + +**Affected components:** Container, Header diff --git a/.changeset/fix-plugin-header-resizeobserver-loop.md b/.changeset/fix-plugin-header-resizeobserver-loop.md new file mode 100644 index 0000000000..b91620d00c --- /dev/null +++ b/.changeset/fix-plugin-header-resizeobserver-loop.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `PluginHeader` to avoid triggering `ResizeObserver loop completed with undelivered notifications` warnings when used in layouts that react to the header height, such as pages that use `FullPage`. + +**Affected components:** PluginHeader diff --git a/.changeset/fix-table-bui-bg-tint-tokens.md b/.changeset/fix-table-bui-bg-tint-tokens.md new file mode 100644 index 0000000000..f1e3857744 --- /dev/null +++ b/.changeset/fix-table-bui-bg-tint-tokens.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fixed Table component to use current `--bui-bg-neutral-1` tokens instead of the removed `--bui-bg-tint` tokens, restoring row hover, selected, pressed, and disabled background colors. diff --git a/.changeset/fix-table-complete-mode-loading.md b/.changeset/fix-table-complete-mode-loading.md new file mode 100644 index 0000000000..8e27f2f23f --- /dev/null +++ b/.changeset/fix-table-complete-mode-loading.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fixed a bug in the `useTable` hook where the loading skeleton was never shown for `complete` mode when using `getData`. The initial data state was an empty array instead of `undefined`, causing the `Table` component to skip the loading state. diff --git a/.changeset/fix-table-row-external-href.md b/.changeset/fix-table-row-external-href.md new file mode 100644 index 0000000000..c3e5b1f494 --- /dev/null +++ b/.changeset/fix-table-row-external-href.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `Table` rows with external `href` values to open in a new tab by automatically applying `target="_blank"` and `rel="noopener noreferrer"`. + +**Affected components**: Table diff --git a/.changeset/giant-carpets-train.md b/.changeset/giant-carpets-train.md new file mode 100644 index 0000000000..aa5aa31a82 --- /dev/null +++ b/.changeset/giant-carpets-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-module-addons-contrib': patch +--- + +Avoid enabling the TechDocs LightBox addon for images wrapped in links, so image links keep working. diff --git a/.changeset/gke-dns-endpoint-support.md b/.changeset/gke-dns-endpoint-support.md new file mode 100644 index 0000000000..54361c5115 --- /dev/null +++ b/.changeset/gke-dns-endpoint-support.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +--- + +Added `endpointType` config option to the GKE cluster locator, allowing use of DNS-based control plane endpoints instead of public IP endpoints. Set `endpointType: 'dns'` to use GKE DNS endpoints (e.g. `gke-..gke.goog`) which provide proper TLS certificates and IAM-based access control. diff --git a/.changeset/gold-squids-rescue.md b/.changeset/gold-squids-rescue.md new file mode 100644 index 0000000000..8ddc6503f1 --- /dev/null +++ b/.changeset/gold-squids-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Adds a new `get-scaffolder-task-logs` action to `@backstage/plugin-scaffolder-backend` that retrieves log events for a given scaffolder task, with optional support for retrieving only new events after a given event ID. diff --git a/.changeset/introduce-cli-modules.md b/.changeset/introduce-cli-modules.md new file mode 100644 index 0000000000..7381742621 --- /dev/null +++ b/.changeset/introduce-cli-modules.md @@ -0,0 +1,15 @@ +--- +'@backstage/cli-module-auth': minor +'@backstage/cli-module-build': minor +'@backstage/cli-module-config': minor +'@backstage/cli-module-github': minor +'@backstage/cli-module-info': minor +'@backstage/cli-module-lint': minor +'@backstage/cli-module-maintenance': minor +'@backstage/cli-module-migrate': minor +'@backstage/cli-module-new': minor +'@backstage/cli-module-test-jest': minor +'@backstage/cli-module-translations': minor +--- + +Initial release of the CLI module packages. Each module provides a set of commands that can be discovered automatically by `@backstage/cli` or executed standalone. diff --git a/.changeset/lovely-corners-refuse.md b/.changeset/lovely-corners-refuse.md new file mode 100644 index 0000000000..b44698f230 --- /dev/null +++ b/.changeset/lovely-corners-refuse.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added `virtualized` prop to `Table` component for virtualized rendering of large datasets. Accepts `true` for default row height, `{ rowHeight: number }` for fixed height, or `{ estimatedRowHeight: number }` for variable height rows. diff --git a/.changeset/mcp-server-name-description.md b/.changeset/mcp-server-name-description.md new file mode 100644 index 0000000000..55d3629cfa --- /dev/null +++ b/.changeset/mcp-server-name-description.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Added `mcpActions.name` and `mcpActions.description` config options to customize the MCP server identity. Namespaced tool names now use dot separator to align with the MCP spec convention. diff --git a/.changeset/mdowryepwlkfidia.md b/.changeset/mdowryepwlkfidia.md new file mode 100644 index 0000000000..f776d7f820 --- /dev/null +++ b/.changeset/mdowryepwlkfidia.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +Removed the `ResolvedExtensionInput` and `ExtensionDataRefToValue` helper types from the public API surface to reduce top-level API clutter. These types were internal plumbing that are not needed by plugin authors. If you were relying on `ResolvedExtensionInput`, use the `ResolvedExtensionInputs` type instead, which maps a full set of inputs. If you were using `ExtensionDataRefToValue`, replace it with `ExtensionDataValue` combined with inferred types from your `ExtensionDataRef`. diff --git a/.changeset/new-frontend-plugin-template-dev-utils.md b/.changeset/new-frontend-plugin-template-dev-utils.md new file mode 100644 index 0000000000..5808a9307a --- /dev/null +++ b/.changeset/new-frontend-plugin-template-dev-utils.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli': patch +'@backstage/cli-module-new': patch +--- + +Updated the new frontend plugin template to use `@backstage/frontend-dev-utils` in its `dev/` entry point instead of wiring `createApp` manually. Generated plugins now get the same dev app helper setup as the built-in examples. diff --git a/.changeset/nfs-icon-alpha-plugins.md b/.changeset/nfs-icon-alpha-plugins.md new file mode 100644 index 0000000000..42c22bbd6e --- /dev/null +++ b/.changeset/nfs-icon-alpha-plugins.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-unprocessed-entities': patch +'@backstage/plugin-devtools': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-user-settings': patch +--- + +Updated alpha plugin icons to follow the new frontend icon sizing rules when rendered in plugin and navigation surfaces. diff --git a/.changeset/nfs-icon-foundations.md b/.changeset/nfs-icon-foundations.md new file mode 100644 index 0000000000..b36ee47573 --- /dev/null +++ b/.changeset/nfs-icon-foundations.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-components': patch +'@backstage/frontend-app-api': patch +'@backstage/frontend-plugin-api': patch +--- + +Clarified the `IconElement` sizing contract for the new frontend system and aligned legacy system icon rendering with the new icon API. diff --git a/.changeset/nfs-icon-plugin-app.md b/.changeset/nfs-icon-plugin-app.md new file mode 100644 index 0000000000..0f6280a6fb --- /dev/null +++ b/.changeset/nfs-icon-plugin-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +The app nav now falls back to `plugin.icon` for navigation items that don't have an explicit icon set. diff --git a/.changeset/ninety-onions-ask.md b/.changeset/ninety-onions-ask.md new file mode 100644 index 0000000000..95798043c9 --- /dev/null +++ b/.changeset/ninety-onions-ask.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Added `BUIProvider` inside the legacy app router to enable client-side routing for all BUI components. diff --git a/.changeset/polite-trains-crash.md b/.changeset/polite-trains-crash.md new file mode 100644 index 0000000000..8926f11e94 --- /dev/null +++ b/.changeset/polite-trains-crash.md @@ -0,0 +1,23 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Centralized client-side routing in `BUIProvider`. Components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table now require a `BUIProvider` rendered inside a React Router context for client-side navigation to work. + +**Migration:** + +This change requires updating `@backstage/plugin-app` and `@backstage/core-app-api` alongside `@backstage/ui`. If you only upgrade `@backstage/ui`, BUI components will fall back to full-page navigation. + +If you cannot upgrade all packages together, or if you have a custom app shell, add a `BUIProvider` inside your Router: + +```diff ++ import { BUIProvider } from '@backstage/ui'; + + ++ + ++ + +``` + +**Affected components:** Link, ButtonLink, Tabs, Menu, TagGroup, Table diff --git a/.changeset/pre.json b/.changeset/pre.json index 62ccda0410..634272b163 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "next", "initialVersions": { "example-app": "0.0.32", diff --git a/.changeset/promote-plugin-wrapper-api.md b/.changeset/promote-plugin-wrapper-api.md new file mode 100644 index 0000000000..b9c6af8589 --- /dev/null +++ b/.changeset/promote-plugin-wrapper-api.md @@ -0,0 +1,9 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: Promoted `PluginWrapperApi`, `pluginWrapperApiRef`, `PluginWrapperBlueprint`, and the new `PluginWrapperDefinition` type from `@alpha` to `@public`. These are now available from the main package entry point rather than only through `/alpha`. + +The `PluginWrapperApi` type now has a required `getRootWrapper()` method that returns a root wrapper component. The `pluginWrapperApiRef` ID changed from `core.plugin-wrapper.alpha` to `core.plugin-wrapper`. + +The `PluginWrapperBlueprint` now accepts `PluginWrapperDefinition` as the loader return type, which supports an optional `useWrapperValue` hook that allows sharing state between wrapper instances. diff --git a/.changeset/promote-plugin-wrapper-app.md b/.changeset/promote-plugin-wrapper-app.md new file mode 100644 index 0000000000..e76d1247b0 --- /dev/null +++ b/.changeset/promote-plugin-wrapper-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Updated the default `PluginWrapperApi` implementation to support the new `useWrapperValue` hook and root wrapper. The root wrapper is now rendered in the app root to manage shared hook state across plugin wrapper instances. diff --git a/.changeset/ready-ghosts-fail.md b/.changeset/ready-ghosts-fail.md new file mode 100644 index 0000000000..a1e4a68602 --- /dev/null +++ b/.changeset/ready-ghosts-fail.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Make the `search` foreign key catalog migration non-blocking on large tables by using batch deletes and PostgreSQL `NOT VALID`/`VALIDATE` to reduce lock duration diff --git a/.changeset/rename-header-css-classes.md b/.changeset/rename-header-css-classes.md new file mode 100644 index 0000000000..664e4d7118 --- /dev/null +++ b/.changeset/rename-header-css-classes.md @@ -0,0 +1,15 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: Renamed internal CSS classes to match the `Header` component name. + +**Migration:**: If you are targeting these classes directly in your styles, update the following: + +- `bui-HeaderPage` → `bui-Header` +- `bui-HeaderPageContent` → `bui-HeaderContent` +- `bui-HeaderPageBreadcrumbs` → `bui-HeaderBreadcrumbs` +- `bui-HeaderPageTabsWrapper` → `bui-HeaderTabsWrapper` +- `bui-HeaderPageControls` → `bui-HeaderControls` + +**Affected components:**: Header diff --git a/.changeset/renovate-422217b.md b/.changeset/renovate-422217b.md new file mode 100644 index 0000000000..fec1d46bfb --- /dev/null +++ b/.changeset/renovate-422217b.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Updated dependency `globals` to `^17.0.0`. diff --git a/.changeset/small-feet-arrive.md b/.changeset/small-feet-arrive.md new file mode 100644 index 0000000000..a0ed7b1b01 --- /dev/null +++ b/.changeset/small-feet-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Moved `BUIProvider` inside the app router to enable automatic client-side routing for all BUI components. diff --git a/.changeset/tidy-friends-act.md b/.changeset/tidy-friends-act.md new file mode 100644 index 0000000000..6db6b77083 --- /dev/null +++ b/.changeset/tidy-friends-act.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed `Card` interactive cards not firing the `onPress` handler when clicking the card surface. + +**Affected components**: Card diff --git a/.changeset/young-squids-end.md b/.changeset/young-squids-end.md new file mode 100644 index 0000000000..9ea2cee92b --- /dev/null +++ b/.changeset/young-squids-end.md @@ -0,0 +1,10 @@ +--- +'@backstage/ui': minor +--- + +Removed redundant `selected` and `indeterminate` props from the `Checkbox` component. Use the `isSelected` and `isIndeterminate` props instead, which are the standard React Aria props and already handle both the checkbox behaviour and the corresponding CSS data attributes. + +**Migration:** +Replace any usage of the `selected` and `indeterminate` props on `Checkbox` with the `isSelected` and `isIndeterminate` props. Note that the checked state and related CSS data attributes (such as `data-selected` and `data-indeterminate`) are now driven by React Aria, so any custom logic that previously inspected or set these via the old props should instead rely on the React Aria-managed state and attributes exposed through the new props. + +**Affected components:** Checkbox diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index fe8306afa8..f3bb31015b 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -555,6 +555,7 @@ validators Valkey varchar viewport +virtualized vite VMware Vodafone diff --git a/.github/workflows/api-breaking-changes-comment.yml b/.github/workflows/api-breaking-changes-comment.yml index aca727743a..82e3310ec1 100644 --- a/.github/workflows/api-breaking-changes-comment.yml +++ b/.github/workflows/api-breaking-changes-comment.yml @@ -23,7 +23,7 @@ jobs: comment-cache-key: ${{ steps.hash.outputs.COMMENT_FILE_HASH }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/api-breaking-changes.yml b/.github/workflows/api-breaking-changes.yml index 4425f81905..2c53aec0de 100644 --- a/.github/workflows/api-breaking-changes.yml +++ b/.github/workflows/api-breaking-changes.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/automate_area-labels.yml b/.github/workflows/automate_area-labels.yml index cdf4a28b55..90b8ea9d16 100644 --- a/.github/workflows/automate_area-labels.yml +++ b/.github/workflows/automate_area-labels.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml index fadb3efb84..34b1791ca8 100644 --- a/.github/workflows/automate_changeset_feedback.yml +++ b/.github/workflows/automate_changeset_feedback.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/automate_merge_message.yml b/.github/workflows/automate_merge_message.yml index c1ecde9194..62db24ce17 100644 --- a/.github/workflows/automate_merge_message.yml +++ b/.github/workflows/automate_merge_message.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/automate_stale.yml b/.github/workflows/automate_stale.yml index 34ab03f207..4bed28af3a 100644 --- a/.github/workflows/automate_stale.yml +++ b/.github/workflows/automate_stale.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml index 0fca26a12c..71b0c68791 100644 --- a/.github/workflows/ci-noop.yml +++ b/.github/workflows/ci-noop.yml @@ -41,7 +41,7 @@ jobs: name: Test ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24854003fa..bb5474506d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: name: Install ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit @@ -78,7 +78,7 @@ jobs: name: Verify ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index a9415b24e1..61f8a2ce51 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -10,7 +10,7 @@ jobs: timeout-minutes: 10 steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml index 61841f4052..18974a3520 100644 --- a/.github/workflows/deploy_docker-image.yml +++ b/.github/workflows/deploy_docker-image.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml index f88f84adad..bc45c28cc1 100644 --- a/.github/workflows/deploy_microsite.yml +++ b/.github/workflows/deploy_microsite.yml @@ -23,40 +23,41 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit - - name: find latest release + - name: find latest release branch uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: find-release with: script: | - const { data } = await github.rest.repos.listTags({ + const data = await octokit.paginate(github.rest.repos.listBranches, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100, }) - const [{tag}] = data + const [{branch}] = data .map(i => i.name) - .filter(tag => tag.match(/^v\d+\.\d+\.\d+$/)) - .map(tag => ({ - tag, - val: tag + .filter(branch => branch.match(/^patch\/v\d+\.\d+\.\d+$/)) + .map(branch => ({ + branch, + val: branch + .split('/')[1] .slice(1) .split('.') .reduce((val, part) => Number(val) * 1000 + Number(part)) })) .sort((a, b) => b.val - a.val) - return tag + return branch result-encoding: string - name: checkout latest release uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: refs/tags/${{ steps.find-release.outputs.result }} + ref: refs/heads/${{ steps.find-release.outputs.result }} - name: Use Node.js 22.x uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -124,7 +125,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit @@ -218,7 +219,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit @@ -232,7 +233,7 @@ jobs: - name: checkout latest release uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: refs/tags/${{ needs.stable.outputs.release }} + ref: refs/heads/${{ needs.stable.outputs.release }} - name: microsite yarn install run: yarn install --immutable diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index 601751c005..37e188a1f0 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -147,7 +147,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml index 791ca69a87..4ba57ebb00 100644 --- a/.github/workflows/issue.yaml +++ b/.github/workflows/issue.yaml @@ -16,7 +16,7 @@ jobs: if: github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/mui-migration-tracker.yml b/.github/workflows/mui-migration-tracker.yml index 36107d33f2..8cd3023cc3 100644 --- a/.github/workflows/mui-migration-tracker.yml +++ b/.github/workflows/mui-migration-tracker.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8f7c462785..aa04752c29 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_bui-docs.yml b/.github/workflows/sync_bui-docs.yml index 15319c19b9..58491479cf 100644 --- a/.github/workflows/sync_bui-docs.yml +++ b/.github/workflows/sync_bui-docs.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml index 2ae13eaa25..38a119edbc 100644 --- a/.github/workflows/sync_code-formatting.yml +++ b/.github/workflows/sync_code-formatting.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_dependabot-changesets.yml b/.github/workflows/sync_dependabot-changesets.yml index 14d2a4d630..2b1dd1098f 100644 --- a/.github/workflows/sync_dependabot-changesets.yml +++ b/.github/workflows/sync_dependabot-changesets.yml @@ -11,7 +11,7 @@ jobs: if: github.actor == 'dependabot[bot]' && github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_patch-release.yml b/.github/workflows/sync_patch-release.yml index 3e96529e53..628635af1d 100644 --- a/.github/workflows/sync_patch-release.yml +++ b/.github/workflows/sync_patch-release.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_pull-requests-scheduled.yml b/.github/workflows/sync_pull-requests-scheduled.yml index 210fc1b20a..3854a0f4df 100644 --- a/.github/workflows/sync_pull-requests-scheduled.yml +++ b/.github/workflows/sync_pull-requests-scheduled.yml @@ -19,7 +19,7 @@ jobs: count: ${{ steps.batch.outputs.count }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit @@ -70,7 +70,7 @@ jobs: max-parallel: 1 steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_pull-requests-trigger.yml b/.github/workflows/sync_pull-requests-trigger.yml index 152837b811..03d29c262d 100644 --- a/.github/workflows/sync_pull-requests-trigger.yml +++ b/.github/workflows/sync_pull-requests-trigger.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_pull-requests.yml b/.github/workflows/sync_pull-requests.yml index 4d607aab13..aa7088bf23 100644 --- a/.github/workflows/sync_pull-requests.yml +++ b/.github/workflows/sync_pull-requests.yml @@ -15,7 +15,7 @@ jobs: github.event.workflow_run.event == 'pull_request' steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_release-manifest.yml b/.github/workflows/sync_release-manifest.yml index 0fee5f17b5..62478a7891 100644 --- a/.github/workflows/sync_release-manifest.yml +++ b/.github/workflows/sync_release-manifest.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_renovate-changesets.yml b/.github/workflows/sync_renovate-changesets.yml index 0940de9c55..04495e606b 100644 --- a/.github/workflows/sync_renovate-changesets.yml +++ b/.github/workflows/sync_renovate-changesets.yml @@ -11,7 +11,7 @@ jobs: if: github.actor == 'renovate[bot]' && github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml index 4a067dbbd3..b04c5b9ab7 100644 --- a/.github/workflows/sync_snyk-github-issues.yml +++ b/.github/workflows/sync_snyk-github-issues.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 985ff6fe9a..8bb4947dab 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/sync_version-packages.yml b/.github/workflows/sync_version-packages.yml index 6cdcddc8be..022ad073c4 100644 --- a/.github/workflows/sync_version-packages.yml +++ b/.github/workflows/sync_version-packages.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility-noop.yml b/.github/workflows/verify_accessibility-noop.yml index 51caba5acd..8cf164ecfa 100644 --- a/.github/workflows/verify_accessibility-noop.yml +++ b/.github/workflows/verify_accessibility-noop.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml index 312cdd2903..83d27612ae 100644 --- a/.github/workflows/verify_accessibility.yml +++ b/.github/workflows/verify_accessibility.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_chromatic-noop.yml b/.github/workflows/verify_chromatic-noop.yml index cb4c96902a..5fe97c8903 100644 --- a/.github/workflows/verify_chromatic-noop.yml +++ b/.github/workflows/verify_chromatic-noop.yml @@ -20,7 +20,7 @@ jobs: name: Chromatic steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_chromatic.yml b/.github/workflows/verify_chromatic.yml index 13be85fe97..26eaeb5f45 100644 --- a/.github/workflows/verify_chromatic.yml +++ b/.github/workflows/verify_chromatic.yml @@ -24,7 +24,7 @@ jobs: name: Chromatic steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 628577e09d..bb6b659db2 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index 6ace7d72a3..792402f7ad 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux-noop.yml b/.github/workflows/verify_e2e-linux-noop.yml index 38ee55f72c..dbba242bae 100644 --- a/.github/workflows/verify_e2e-linux-noop.yml +++ b/.github/workflows/verify_e2e-linux-noop.yml @@ -29,7 +29,7 @@ jobs: name: E2E Linux ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml index 4e0f82dd2c..66a176e128 100644 --- a/.github/workflows/verify_e2e-linux.yml +++ b/.github/workflows/verify_e2e-linux.yml @@ -43,7 +43,7 @@ jobs: name: E2E Linux ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-techdocs.yml b/.github/workflows/verify_e2e-techdocs.yml index f3786f19c3..370d10310d 100644 --- a/.github/workflows/verify_e2e-techdocs.yml +++ b/.github/workflows/verify_e2e-techdocs.yml @@ -32,7 +32,7 @@ jobs: name: Techdocs steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-windows-noop.yml b/.github/workflows/verify_e2e-windows-noop.yml index 4759555905..baa18cb53b 100644 --- a/.github/workflows/verify_e2e-windows-noop.yml +++ b/.github/workflows/verify_e2e-windows-noop.yml @@ -25,7 +25,7 @@ jobs: name: E2E Windows ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index 2b0e3d47c3..f4fded1a0d 100644 --- a/.github/workflows/verify_e2e-windows.yml +++ b/.github/workflows/verify_e2e-windows.yml @@ -33,7 +33,7 @@ jobs: name: E2E Windows ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_fossa.yml b/.github/workflows/verify_fossa.yml index 84d666f00e..80cd7907e2 100644 --- a/.github/workflows/verify_fossa.yml +++ b/.github/workflows/verify_fossa.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite-noop.yml b/.github/workflows/verify_microsite-noop.yml index acab96962c..08eeac4431 100644 --- a/.github/workflows/verify_microsite-noop.yml +++ b/.github/workflows/verify_microsite-noop.yml @@ -21,7 +21,7 @@ jobs: name: Microsite steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite.yml b/.github/workflows/verify_microsite.yml index 4fb6a98f3e..b8ce5bfb93 100644 --- a/.github/workflows/verify_microsite.yml +++ b/.github/workflows/verify_microsite.yml @@ -28,40 +28,41 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit - - name: find latest release + - name: find latest release branch uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 id: find-release with: script: | - const { data } = await github.rest.repos.listTags({ + const data = await octokit.paginate(github.rest.repos.listBranches, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100, }) - const [{tag}] = data + const [{branch}] = data .map(i => i.name) - .filter(tag => tag.match(/^v\d+\.\d+\.\d+$/)) - .map(tag => ({ - tag, - val: tag + .filter(branch => branch.match(/^patch\/v\d+\.\d+\.\d+$/)) + .map(branch => ({ + branch, + val: branch + .split('/')[1] .slice(1) .split('.') .reduce((val, part) => Number(val) * 1000 + Number(part)) })) .sort((a, b) => b.val - a.val) - return tag + return branch result-encoding: string - name: checkout latest release uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: refs/tags/${{ steps.find-release.outputs.result }} + ref: refs/heads/${{ steps.find-release.outputs.result }} - name: Use Node.js 22.x uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -126,7 +127,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit @@ -212,7 +213,7 @@ jobs: name: Microsite steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility-noop.yml b/.github/workflows/verify_microsite_accessibility-noop.yml index 87963dd78e..a90b892fae 100644 --- a/.github/workflows/verify_microsite_accessibility-noop.yml +++ b/.github/workflows/verify_microsite_accessibility-noop.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility.yml b/.github/workflows/verify_microsite_accessibility.yml index ff7a91c716..226f1f71be 100644 --- a/.github/workflows/verify_microsite_accessibility.yml +++ b/.github/workflows/verify_microsite_accessibility.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 26657cbcd3..3e74e088ec 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 + uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit diff --git a/AGENTS.md b/AGENTS.md index 001989d9b6..010ebca9e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,9 @@ Backstage is an open platform for building developer portals. This is a TypeScri - `/packages`: Core framework packages (prefixed `@backstage/`) - `/plugins`: Plugin packages (prefixed `@backstage/plugin-*`) -- `/packages/app` and `/packages/backend`: Example app for local development - `/packages/app`: Main example app using the new frontend system - `/packages/app-legacy`: Example app using the old frontend system +- `/packages/backend`: Example backend for local development - `/docs`: Documentation files Packages prefixed with `core-` (e.g., `@backstage/core-plugin-api`) are part of the old frontend system. Packages prefixed with `frontend-` (e.g., `@backstage/frontend-plugin-api`) are part of the new frontend system (NFS). Packages prefixed with `backend-` (e.g., `@backstage/backend-plugin-api`) are part of the backend system. diff --git a/app-config.yaml b/app-config.yaml index e88955a39f..5eea93084e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -27,6 +27,130 @@ app: packageName: example-app + routes: + bindings: + catalog.viewTechDoc: techdocs.docRoot + org.catalogIndex: catalog.catalogIndex + + pluginOverrides: + - match: + pluginId: pages + info: + description: 'This description was overridden in app-config.yaml' + - match: + pluginId: /^catalog(-.*)?$/ + info: + ownerEntityRefs: [cubic-belugas] + - match: + packageName: '@backstage/plugin-scaffolder' + info: + ownerEntityRefs: [cubic-belugas] + + extensions: + # set availableLanguages example + - api:app/app-language: + config: + availableLanguages: ['en', 'es', 'fr', 'de', 'ja'] + defaultLanguage: 'en' + - entity-card:org/members-list: + config: + showAggregateMembersToggle: true + initialRelationAggregation: aggregated + - entity-card:org/ownership: + config: + ownedKinds: ['Component', 'API', 'System'] + + # - apis.plugin.graphiql.browse.gitlab: true + # - graphiql-endpoint:graphiql/gitlab: true + + - nav-item:search: false + - nav-item:user-settings: false + - nav-item:catalog + - nav-item:api-docs + - nav-item:scaffolder + - nav-item:app-visualizer + + # Pages + - page:catalog/entity: + config: + showNavItemIcons: true + # default content order for all groups, can be 'title' or 'natural' + # defaultContentOrder: title + groups: + # placing a tab at the beginning + - overview: + title: Overview + # example disabling a default group + # - development: false + # example overriding a default group title + - documentation: + title: Docs + icon: docs + # example aliasing a group + # aliases: + # - docs + - deployment: + title: Deployments + # example adding a new group + - custom: + title: Custom + + # Entity page cards + - entity-card:catalog/about: + config: + type: info + - entity-card:catalog/labels + - entity-card:catalog/links: + config: + filter: + kind: component + metadata.links: + $exists: true + # filter: kind:component has:links + type: info + # - entity-card:linguist/languages + - entity-card:catalog-graph/relations: + config: + height: 300 + - entity-card:api-docs/has-apis + - entity-card:api-docs/consumed-apis + - entity-card:api-docs/provided-apis + - entity-card:api-docs/providing-components + - entity-card:api-docs/consuming-components + # Org Plugin + - entity-card:org/group-profile + - entity-card:org/members-list + - entity-card:org/ownership + - entity-card:org/user-profile: + config: + maxRelations: 5 + hideIcons: true + # - entity-card:azure-devops/readme + + # Entity page contents + - entity-content:catalog/overview + - entity-content:api-docs/definition + - entity-content:api-docs/apis: + config: + # example overriding the default group + group: documentation + icon: kind:api + - entity-content:techdocs: + config: + icon: techdocs + - entity-content:kubernetes/kubernetes: + config: + # example disassociating from the default group + group: false + # - entity-content:azure-devops/pipelines + # - entity-content:azure-devops/pull-requests + # - entity-content:azure-devops/git-tags + + # Enable the catalog-unprocessed-entities tab in devtools + - devtools-content:catalog-unprocessed-entities: true + # Disable the catalog-unprocessed-entities element outside devtools + - page:catalog-unprocessed-entities: false + backend: # Used for enabling authentication, secret is shared by all backend plugins # See https://backstage.io/docs/auth/service-to-service-auth for diff --git a/docs-ui/src/app/components/search-autocomplete/components.tsx b/docs-ui/src/app/components/search-autocomplete/components.tsx new file mode 100644 index 0000000000..4052ee0e3f --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/components.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; +import { + SearchAutocomplete, + SearchAutocompleteItem, +} from '../../../../../packages/ui/src/components/SearchAutocomplete/SearchAutocomplete'; +import { PluginHeader } from '../../../../../packages/ui/src/components/PluginHeader/PluginHeader'; +import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; +import { Text } from '../../../../../packages/ui/src/components/Text/Text'; +import { MemoryRouter } from 'react-router-dom'; + +const fruits = [ + { id: 'apple', name: 'Apple', description: 'A round fruit' }, + { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' }, + { id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' }, + { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' }, + { id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' }, + { id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' }, + { id: 'mango', name: 'Mango', description: 'A tropical stone fruit' }, + { id: 'orange', name: 'Orange', description: 'A citrus fruit' }, + { id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' }, + { + id: 'strawberry', + name: 'Strawberry', + description: 'A red fruit with seeds on its surface', + }, +]; + +export const WithItems = () => { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); +}; + +export const WithRichContent = () => { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); +}; + +export const InHeader = () => { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + } + /> + + ); +}; + +export const WithSelection = () => { + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); +}; diff --git a/docs-ui/src/app/components/search-autocomplete/page.mdx b/docs-ui/src/app/components/search-autocomplete/page.mdx new file mode 100644 index 0000000000..ecb58f3377 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/page.mdx @@ -0,0 +1,95 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; +import { + searchAutocompletePropDefs, + searchAutocompleteItemPropDefs, +} from './props-definition'; +import { + usage, + defaultSnippet, + withRichContent, + inHeader, + withSelection, +} from './snippets'; +import { + WithItems, + WithRichContent, + InHeader, + WithSelection, +} from './components'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { CodeBlock } from '@/components/CodeBlock'; +import { + SearchAutocompleteDefinition, + SearchAutocompleteItemDefinition, +} from '../../../utils/definitions'; + +export const reactAriaUrls = { + autocomplete: 'https://react-aria.adobe.com/Autocomplete', +}; + + + +} code={defaultSnippet} /> + +## Usage + + + +## API reference + +### SearchAutocomplete + + + + + +### SearchAutocompleteItem + +Individual result item within the autocomplete list. + + + +## Examples + +### With rich content + +Here's a view when items include a title and description. + +} + code={withRichContent} +/> + +### In header + +Use `popoverWidth` to control the dropdown width when placed in constrained layouts like `PluginHeader`. + +} code={inHeader} /> + +### With selection + +Use `onAction` on items to handle selection and reset the input. + +} + code={withSelection} +/> + + + + + + diff --git a/docs-ui/src/app/components/search-autocomplete/props-definition.tsx b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx new file mode 100644 index 0000000000..7ddaea61e2 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx @@ -0,0 +1,97 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +export const searchAutocompletePropDefs: Record = { + 'aria-label': { + type: 'string', + description: 'Accessible label for the search input.', + }, + 'aria-labelledby': { + type: 'string', + description: 'ID of the element that labels the search input.', + }, + inputValue: { + type: 'string', + description: 'The current input value (controlled).', + }, + onInputChange: { + type: 'enum', + values: ['(value: string) => void'], + description: 'Handler called when the input value changes.', + }, + placeholder: { + type: 'string', + default: 'Search', + description: + 'Placeholder text shown when the input is empty. Also used as the accessible label when neither aria-label nor aria-labelledby is provided.', + }, + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + responsive: true, + description: ( + <> + Visual size of the input. Use small for inline or dense + layouts, medium for standalone fields. + + ), + }, + isLoading: { + type: 'boolean', + default: 'false', + description: + 'Whether results are currently loading. Dims existing results and announces loading state to screen readers.', + }, + popoverWidth: { + type: 'string', + description: + 'Width of the results popover. Accepts any CSS width value. Matches the input width when not set.', + }, + popoverPlacement: { + type: 'enum', + values: ['bottom start', 'bottom end', 'top start', 'top end'], + default: 'bottom start', + description: 'Placement of the results popover relative to the input.', + }, + defaultOpen: { + type: 'boolean', + default: 'false', + description: 'Whether the results popover is open by default.', + }, + children: { + type: 'enum', + values: ['ReactNode'], + description: 'The result items to render inside the autocomplete.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; + +export const searchAutocompleteItemPropDefs: Record = { + id: { + type: 'string', + description: 'Unique identifier for the item.', + }, + textValue: { + type: 'string', + description: + 'Plain text value used for keyboard navigation and accessibility.', + }, + onAction: { + type: 'enum', + values: ['() => void'], + description: 'Handler called when the item is selected.', + }, + children: { + type: 'enum', + values: ['ReactNode'], + required: true, + description: 'Content to render inside the item.', + }, + ...classNamePropDefs, +}; diff --git a/docs-ui/src/app/components/search-autocomplete/snippets.ts b/docs-ui/src/app/components/search-autocomplete/snippets.ts new file mode 100644 index 0000000000..fe799ff828 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/snippets.ts @@ -0,0 +1,139 @@ +export const usage = `import { SearchAutocomplete, SearchAutocompleteItem } from '@backstage/ui'; + + + {items.map(item => ( + + {item.name} + + ))} +`; + +export const defaultSnippet = `const fruits = [ + { id: 'apple', name: 'Apple' }, + { id: 'banana', name: 'Banana' }, + { id: 'cherry', name: 'Cherry' }, + { id: 'grape', name: 'Grape' }, + { id: 'orange', name: 'Orange' }, +]; + +function Example() { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); +}`; + +export const withRichContent = `const fruits = [ + { id: 'apple', name: 'Apple', description: 'A round fruit' }, + { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' }, + { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' }, +]; + +function Example() { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); +}`; + +export const inHeader = ` + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + } +/>`; + +export const withSelection = `function Example() { + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); +}`; diff --git a/docs-ui/src/app/components/table/page.mdx b/docs-ui/src/app/components/table/page.mdx index 6eb280cbe2..8d0e803bd2 100644 --- a/docs-ui/src/app/components/table/page.mdx +++ b/docs-ui/src/app/components/table/page.mdx @@ -42,6 +42,7 @@ import { tableCombinedSnippet, tableCustomRowSnippet, tablePrimitivesSnippet, + tableCellRequirementSnippet, } from './snippets'; import { ChangelogComponent } from '@/components/ChangelogComponent'; import { PageTitle } from '@/components/PageTitle'; @@ -94,6 +95,18 @@ For full control over state, use the controlled props instead: - `search` / `onSearchChange` - `filter` / `onFilterChange` +### Cell Requirement + +Every cell rendered via `ColumnConfig.cell` (or inside a custom `RowRenderFn`) **must** return a cell component as the top-level element. The available cell components are: + +- **`CellText`** — displays a title with optional description and icon. +- **`CellProfile`** — displays an avatar with a name and optional description. +- **`Cell`** — a generic wrapper for fully custom cell content. + +Returning bare text, React fragments, or other elements without wrapping them in one of these cell components will break the table layout. + + + ## Common Patterns ### Sorting diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx index abfdd45549..6d35cb3350 100644 --- a/docs-ui/src/app/components/table/props-definition.tsx +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -430,6 +430,17 @@ export const tableRootPropDefs: Record = { ), }, + loading: { + type: 'boolean', + default: 'false', + description: ( + <> + Whether the table is in a loading state (e.g., initial data fetch). Adds{' '} + aria-busy attribute and data-loading data + attribute for styling. + + ), + }, }; export const columnPropDefs: Record = { diff --git a/docs-ui/src/app/components/table/snippets.ts b/docs-ui/src/app/components/table/snippets.ts index ab4bf53a58..39f266f2ea 100644 --- a/docs-ui/src/app/components/table/snippets.ts +++ b/docs-ui/src/app/components/table/snippets.ts @@ -57,6 +57,23 @@ const { filter, // { value, onChange } for filters } = useTable({ ... });`; +// ============================================================================= +// Cell Requirement +// ============================================================================= + +export const tableCellRequirementSnippet = `// ✅ Correct — CellText is the top-level element +cell: item => ; + +// ✅ Correct — Cell wraps custom content +cell: item => ( + + + +); + +// ❌ Wrong — bare text without a cell wrapper +cell: item => {item.name};`; + // ============================================================================= // Common Patterns // ============================================================================= diff --git a/docs-ui/src/app/get-started/installation/page.mdx b/docs-ui/src/app/get-started/installation/page.mdx index c5421ae9d7..d87c2bcba4 100644 --- a/docs-ui/src/app/get-started/installation/page.mdx +++ b/docs-ui/src/app/get-started/installation/page.mdx @@ -49,23 +49,28 @@ your plugin and import the components you need. -## Analytics +## BUIProvider -BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — can fire analytics events when clicked. To enable this, you need to connect BUI's analytics layer to Backstage's analytics system. +`BUIProvider` provides routing and analytics integration for all BUI components. It must be rendered inside a React Router context for client-side navigation to work in components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table. ### Setup -If you're using the **new frontend system**, analytics is wired automatically via `@backstage/plugin-app` — no setup required. +If you're using the **new frontend system**, the provider is wired automatically via `@backstage/plugin-app` — no setup required. For the **old frontend system**, the `BUIProvider` is included in the app shell from `@backstage/core-app-api` and works out of the box. -If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` and pass in Backstage's `useAnalytics` hook: +If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` inside your Router and pass in Backstage's `useAnalytics` hook: -### How it works + -Once configured, BUI components fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree. +### Analytics + +Once configured, BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree. To suppress tracking on an individual component, use the `noTrack` prop: diff --git a/docs-ui/src/app/get-started/installation/snippets.ts b/docs-ui/src/app/get-started/installation/snippets.ts index 12dfff4f44..61edb266b3 100644 --- a/docs-ui/src/app/get-started/installation/snippets.ts +++ b/docs-ui/src/app/get-started/installation/snippets.ts @@ -7,11 +7,14 @@ export const snippet = `import { Flex, Button, Text } from '@backstage/ui'; export const analyticsSetupSnippet = `import { BUIProvider } from '@backstage/ui'; import { useAnalytics } from '@backstage/core-plugin-api'; +import { BrowserRouter } from 'react-router-dom'; -// Wrap your app content with the provider - - -`; +// BUIProvider must be inside a Router for client-side navigation + + + + +`; export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 753473ab64..fc1f841b55 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -85,6 +85,10 @@ export const components: Page[] = [ title: 'RadioGroup', slug: 'radio-group', }, + { + title: 'SearchAutocomplete', + slug: 'search-autocomplete', + }, { title: 'SearchField', slug: 'search-field', diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts index c2c383796f..670bee3f05 100644 --- a/docs-ui/src/utils/types.ts +++ b/docs-ui/src/utils/types.ts @@ -23,6 +23,7 @@ export type Component = | 'password-field' | 'radio-group' | 'scrollarea' + | 'search-autocomplete' | 'searchfield' | 'select' | 'skeleton' diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md new file mode 100644 index 0000000000..498cd42df2 --- /dev/null +++ b/docs/ai/mcp-actions.md @@ -0,0 +1,256 @@ +--- +id: mcp-actions +title: MCP Actions Backend +description: The MCP Actions Backend exposes actions registered with the Actions Registry as MCP tools. +--- + +The MCP Actions Backend exposes [Actions](../backend-system/core-services/actions.md) registered with the [Actions Registry](../backend-system/core-services/actions-registry.md) as MCP tools. + +## Installation + +This plugin is installed via the `@backstage/plugin-mcp-actions-backend` package. To add it to your backend package, run the following command: + +```bash title="From your root directory" +yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend +``` + +Then, add the plugin to your backend: + +```ts title="packages/backend/src/index.ts" +const backend = createBackend(); +// ... +backend.add(import('@backstage/plugin-mcp-actions-backend')); +// ... +backend.start(); +``` + +## Actions Configuration + +Populate the `pluginSources` configuration with the list of plugins you want exposed as MCP tools like so: + +```yaml +backend: + actions: + pluginSources: + - 'catalog' + - 'my-custom-plugin' +``` + +For details on filtering actions, see the [filtering actions documentation](../backend-system/core-services/actions.md#filtering-actions). + +## Single MCP Server Name & Description + +You can configure the name and description of your Backstage MCP server with the following config: + +```yaml title="app-config.yaml" +mcpActions: + name: 'My MCP Server' # defaults to "backstage" + description: 'Tools for interacting with My MCP Server' # optional +``` + +## Namespaced Tool Names + +By default, MCP tool names include the plugin ID prefix to avoid collisions across plugins. For example, an action registered as `greet-user` by `my-custom-plugin` is exposed as `my-custom-plugin.greet-user`. + +You can disable this if you need the short names for backward compatibility: + +```yaml title="app-config.yaml" +mcpActions: + namespacedToolNames: false +``` + +## Multiple MCP Servers + +By default, the plugin serves a single MCP server at `/api/mcp-actions/v1` that exposes all available actions. You can split actions into multiple focused servers by configuring `mcpActions.servers`, where each key becomes a separate MCP server endpoint. + +```yaml title="app-config.yaml" +mcpActions: + servers: + catalog: + name: 'Backstage Catalog' + description: 'Tools for interacting with the software catalog' + filter: + include: + - id: 'catalog:*' + scaffolder: + name: 'Backstage Scaffolder' + description: 'Tools for creating new software from templates' + filter: + include: + - id: 'scaffolder:*' +``` + +This creates two MCP server endpoints: + +- `http://localhost:7007/api/mcp-actions/v1/catalog` +- `http://localhost:7007/api/mcp-actions/v1/scaffolder` + +Each server uses include filter rules with glob patterns on action IDs to control which actions are exposed. For example, `id: 'catalog:*'` matches all actions registered by the catalog plugin. + +When `mcpActions.servers` is not configured, the plugin behaves exactly as before with a single server at `/api/mcp-actions/v1`. + +### Filter Rules + +Include and exclude filter rules support glob patterns on action IDs and attribute matching. Exclude rules take precedence over include rules. When include rules are specified, actions must match at least one include rule to be exposed. + +```yaml title="app-config.yaml" +mcpActions: + servers: + catalog: + name: 'Backstage Catalog' + filter: + include: + - id: 'catalog:*' + exclude: + - attributes: + destructive: true +``` + +## Authentication Configuration + +By default, the Backstage backend requires authentication for all requests. + +### External Access with Static Tokens + +:::warning +This is meant to be a temporary workaround until device authentication is completed. +::: + +Configure external access with static tokens in your app configuration: + +```yaml title="app-config.yaml" +backend: + auth: + externalAccess: + - type: static + options: + token: ${MCP_TOKEN} + subject: mcp-clients + accessRestrictions: + - plugin: mcp-actions + - plugin: catalog +``` + +Generate a secure token: + +```bash +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + +Set the `MCP_TOKEN` environment variable and configure your MCP client to send: + +```http +Authorization: Bearer +``` + +For more details about external access tokens and service-to-service authentication, see the +[Service-to-Service Auth documentation](../auth/service-to-service-auth.md). + +### Experimental: Dynamic Client Registration + +:::warning +This feature is highly experimental and only works with the New Frontend System. Proceed with caution. +::: + +You can configure the auth-backend and install the auth frontend plugin to enable **Dynamic Client Registration** with MCP clients. This means you do not need to manually configure a token in your MCP client settings. Instead, a client can request a token on your behalf. When adding the MCP server to an MCP client like Cursor or Claude, a popup requiring your approval will open in your Backstage instance (powered by the auth plugin). + +**Requirements:** + +- The `@backstage/plugin-auth-backend` plugin must be configured. +- The new `@backstage/plugin-auth` frontend plugin must be configured. + +**Installation:** + +1. Install the `@backstage/plugin-auth` frontend plugin: + + ```bash + yarn --cwd packages/app add @backstage/plugin-auth + ``` + +2. If you use [feature discovery](../frontend-system/architecture/10-app.md#feature-discovery) the plugin will be added automatically, if you prefer explicit registration, register the plugin as a feature like this: + + ```tsx title="packages/app/src/App.tsx" + import authPlugin from '@backstage/plugin-auth'; + + const app = createApp({ + features: [ + // ...other features + authPlugin, + ], + }); + ``` + +3. Enable the feature: + + ```yaml title="app-config.yaml" + auth: + experimentalDynamicClientRegistration: + enabled: true + + # Optional: limit valid callback URLs for added security + allowedRedirectUriPatterns: + - cursor://* + ``` + +## Configuring MCP Clients + +The MCP server supports both **Server-Sent Events (SSE)** and **Streamable HTTP** protocols. + +:::warning +The SSE protocol is deprecated and will be removed in a future release. +::: + +### Endpoints + +- **Streamable HTTP:** `http://localhost:7007/api/mcp-actions/v1` +- **SSE (deprecated):** `http://localhost:7007/api/mcp-actions/v1/sse` + +```json +{ + "mcpServers": { + "backstage-actions": { + "url": "http://localhost:7007/api/mcp-actions/v1", + "headers": { + "Authorization": "Bearer ${MCP_TOKEN}" + } + } + } +} +``` + +The `${MCP_TOKEN}` environment variable would be an [external access static token](#external-access-with-static-tokens). + +### Multiple Servers + +When `mcpActions.servers` is configured, each server key becomes part of the URL. For example, with servers named `catalog` and `scaffolder`: + +- `http://localhost:7007/api/mcp-actions/v1/catalog` +- `http://localhost:7007/api/mcp-actions/v1/scaffolder` + +```json +{ + "mcpServers": { + "backstage-catalog": { + "url": "http://localhost:7007/api/mcp-actions/v1/catalog", + "headers": { + "Authorization": "Bearer ${MCP_TOKEN}" + } + }, + "backstage-scaffolder": { + "url": "http://localhost:7007/api/mcp-actions/v1/scaffolder", + "headers": { + "Authorization": "Bearer ${MCP_TOKEN}" + } + } + } +} +``` + +## Metrics + +The MCP Actions Backend emits metrics for the following operations: + +- `mcp.server.operation.duration`: The duration taken to process an individual MCP operation +- `mcp.server.session.duration`: The duration of the MCP session from the perspective of the server + +See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn how to make these metrics available. diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md new file mode 100644 index 0000000000..d6de8407cf --- /dev/null +++ b/docs/ai/well-known-actions.md @@ -0,0 +1,30 @@ +--- +id: well-known-actions +title: Well-known Actions +description: This section lists a number of well-known actions that are part of the Actions Registry. +--- + +This section lists a number of well-known [Actions](../backend-system/core-services/actions.md) registered with the [Actions Registry](../backend-system/core-services/actions-registry.md). + +## Actions + +This is a (non-exhaustive) list of actions that are known to be part of the Actions Registry. Entries are in the format: "`action-name` (Action Title): Shortened Action Description" + +### Auth + +- `auth.who-am-i` (Who Am I): Returns the catalog entity and user info for the currently authenticated user. This action requires user credentials and cannot be used with service or unauthenticated credentials. + +### Catalog + +- `catalog.get-catalog-entity` (Get Catalog Entity): This allows you to get a single entity from the software catalog. +- `catalog.query-catalog-entities` (Query Catalog Entities): Query entities from the Backstage Software Catalog using predicate filters. +- `catalog.register-entity` (Register entity in the Catalog): Registers one or more entities in the Backstage catalog by creating a Location entity that points to a remote `catalog-info.yaml` file. +- `catalog.unregister-entity` (Unregister entity from the Catalog): Unregisters a Location entity and all entities it owns from the Backstage catalog. +- `catalog.validate-entity` (Validate Catalog Entity): This action can be used to validate `catalog-info.yaml` file contents meant to be used with the software catalog. + +### Scaffolder + +- `scaffolder.dry-run-template` (Dry Run Scaffolder Template): Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures. +- `scaffolder.list-scaffolder-actions` (List Scaffolder Actions): Lists all installed Scaffolder actions. +- `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created. +- `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task. diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md index 84c0300543..136d70778e 100644 --- a/docs/api/deprecations.md +++ b/docs/api/deprecations.md @@ -83,7 +83,7 @@ migrate to your own custom API. First, you'll need to define a new Utility API reference. If you're only using the API for sign-in, you can put the definition in `packages/app/src/apis.ts`. -However, if you need to access your auth API inside plugins you you'll need to +However, if you need to access your auth API inside plugins you'll need to export it from a common package. If you don't already have one, we recommend creating `@internal/apis` and from there exporting the API reference. diff --git a/docs/contribute/project-structure.md b/docs/contribute/project-structure.md index 372fb79844..f1be29c312 100644 --- a/docs/contribute/project-structure.md +++ b/docs/contribute/project-structure.md @@ -144,7 +144,10 @@ are separated out into their own folder, see further down. - [`dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/dev-utils) - Helps you setup a plugin for isolated development so that it can be served - separately. + separately. This is for plugins using the legacy frontend system. + +- [`frontend-dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/frontend-dev-utils) - + Utilities for developing frontend plugins using the new frontend system. Provides the `createDevApp` helper for setting up a minimal development app in a plugin's `dev/` entry point. - [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) - Another CLI that can be run to try out what would happen if you build all the diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index 97f579c5a3..2a0e20cbdc 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -150,7 +150,54 @@ Some more real world usable examples: #### Full text filtering -TODO +You can perform a text search across entity fields using the `fullTextFilterTerm` +query parameter. This performs a case-insensitive substring match against the +values in the entity YAML fields. + +By default, when no `fullTextFilterFields` parameter is specified, the search +runs against the current sort field (from `orderField`), or `metadata.uid` if +no sort field is set. This means that without specifying fields explicitly, the +search may not match against the fields you expect. + +To control which fields are searched, pass the `fullTextFilterFields` query +parameter as a comma-separated list of entity field paths. + +Query parameters: + +- `fullTextFilterTerm` - The text to search for (case insensitive, substring match) +- `fullTextFilterFields` - A comma-separated list of entity field paths to + search against (e.g. `metadata.name,metadata.title`) + +Example: + +```text +/entities/by-query?fullTextFilterTerm=my-service&fullTextFilterFields=metadata.name,metadata.title + + Return entities whose metadata.name OR metadata.title contains "my-service" +``` + +Some more real world usable examples: + +- Search for components by name: + + `/entities/by-query?filter=kind=component&fullTextFilterTerm=payment&fullTextFilterFields=metadata.name` + +- Search across both name and title: + + `/entities/by-query?filter=kind=system&fullTextFilterTerm=platform&fullTextFilterFields=metadata.name,metadata.title` + +- Combine with other filters (e.g. owned by a specific group): + + `/entities/by-query?filter=kind=component,relations.ownedBy=group:default/my-team&fullTextFilterTerm=api&fullTextFilterFields=metadata.name` + +:::note Note + +Full text filtering is mutually exclusive with cursor-based pagination. When a +`cursor` is provided, `fullTextFilterTerm` and `fullTextFilterFields` are +ignored — the cursor already encodes the original filter parameters from the +initial request. + +::: #### Field selection diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index 8c9241eb36..6ceb00b3b5 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -402,3 +402,46 @@ spec: definition: $openapi: ./spec/openapi.yaml # by using $openapi Backstage will now resolve all $ref instances ``` + +## Backstage OpenAPI Module + +As Backstage increasingly uses OpenAPI to define its core APIs (such as the Catalog and Scaffolder), discovering and interacting with these APIs is essential for integrating external tools. + +You can install the **Backstage OpenAPI Module** to easily expose the OpenAPI specifications for your Backstage instance plugins directly into the catalog. + +### Installation + +1. Install the module in your backend: + +```bash +yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-backstage-openapi +``` + +2. Register the module in your backend: + +```ts title="packages/backend/src/index.ts" +backend.add( + import('@backstage/plugin-catalog-backend-module-backstage-openapi'), +); +``` + +3. Add the configuration to your `app-config.yaml`: + +```yaml title="app-config.yaml" +catalog: + providers: + backstageOpenapi: + plugins: + - catalog + - scaffolder + # Optional configuration: + # definitionFormat controls how generated definitions are serialized. + # Supported values: 'json' (default) or 'yaml'. + # definitionFormat: json + # entityOverrides can be used to override parts of the produced entities. + # For example, to add a tag to all generated APIs: + # entityOverrides: + # metadata: + # tags: + # - from-openapi +``` diff --git a/docs/features/techdocs/addons--new.md b/docs/features/techdocs/addons--new.md index af7f4d2893..170bd80f70 100644 --- a/docs/features/techdocs/addons--new.md +++ b/docs/features/techdocs/addons--new.md @@ -89,12 +89,12 @@ page header, TechDocs Addons whose location is `Header` will not be rendered. Addons can, in principle, be provided by any plugin! To make it easier to discover available Addons, we've compiled a list of them here: -| Addon | Package/Plugin | Description | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsExpandableNavigationAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | -| [`techDocsReportIssueAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsReportIssueAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | -| [`techDocsTextSizeAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsTextSizeAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | -| [`techDocsLightBoxAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsLightBoxAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). | +| Addon | Package/Plugin | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsExpandableNavigationAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | +| [`techDocsReportIssueAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsReportIssueAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | +| [`techDocsTextSizeAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsTextSizeAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | +| [`techDocsLightBoxAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsLightBoxAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). Images inside links are ignored to avoid blocking navigation. | Got an Addon to contribute? Feel free to add a row above! diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md index 78b472edf2..72e8aa39e8 100644 --- a/docs/features/techdocs/addons.md +++ b/docs/features/techdocs/addons.md @@ -126,12 +126,12 @@ page header, TechDocs Addons whose location is `Header` will not be rendered. Addons can, in principle, be provided by any plugin! To make it easier to discover available Addons, we've compiled a list of them here: -| Addon | Package/Plugin | Description | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ExpandableNavigation.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | -| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ReportIssue.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | -| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.TextSize.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | -| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.LightBox.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). | +| Addon | Package/Plugin | Description | +| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ExpandableNavigation.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | +| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ReportIssue.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | +| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.TextSize.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | +| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.LightBox.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). Images inside links are ignored to avoid blocking navigation. | Got an Addon to contribute? Feel free to add a row above! diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 0303dd5e70..22457bcb30 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -46,31 +46,7 @@ App feature discovery lets you automatically discover and install features provi Because feature discovery needs to interact with the compilation process, it is only available when using the `@backstage/cli` to build your app. It is hooked into the WebPack compilation process by scanning your app package for compatible dependencies, which are then made part of the app compilation bundle. -To enable frontend feature discovery, add the following configuration to your `app-config.yaml`: - -```yaml -app: - packages: all -``` - -This will cause all dependencies in your app package to be installed automatically. If this is not desired, you can use include or exclude filters to narrow down the set of packages: - -```yaml -app: - packages: - # Only the following packages will be included - include: - - '@backstage/plugin-catalog' - - '@backstage/plugin-scaffolder' ---- -app: - packages: - # All but the following package will be included - exclude: - - '@backstage/plugin-catalog' -``` - -Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs. +For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md). ## Plugin Info Resolution diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md index 8610dfb7b5..bd9a0fbae5 100644 --- a/docs/frontend-system/building-apps/01-index.md +++ b/docs/frontend-system/building-apps/01-index.md @@ -63,13 +63,9 @@ Visit the [built-in extensions](#customize-or-override-built-in-extensions) sect Linking routes from different plugins requires this configuration. You can do this either through a configuration file or by coding, visit [this](https://backstage.io/docs/frontend-system/architecture/routes#binding-external-route-references) page for instructions. -### Enable feature discovery +### Install plugins -Use this setting to enable experimental feature discovery when building your app with `@backstage/cli`. With this configuration your application tries to discover and install package extensions automatically, check [here](../architecture/10-app.md#feature-discovery) for more details. - -:::warning -Remember that package extensions that are not auto-discovered must be manually added to the application when creating an app. See [features](#install-features-manually) for more details. -::: +Plugins are typically installed by adding them as dependencies of your app package and relying on feature discovery to automatically detect them. For details on how this works, including how to manually install plugins or control which packages are discovered, see [Installing Plugins](./05-installing-plugins.md). ### Configure extensions individually @@ -83,7 +79,7 @@ Previously you would customize the application routes, components, apis, sidebar ### Install features manually -A manual installation is required if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function: +Most plugins are installed automatically through [feature discovery](./05-installing-plugins.md#feature-discovery). Manual installation is needed if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function: ```tsx title="packages/app/src/App.tsx" import { createApp } from '@backstage/frontend-defaults'; diff --git a/docs/frontend-system/building-apps/05-installing-plugins.md b/docs/frontend-system/building-apps/05-installing-plugins.md new file mode 100644 index 0000000000..9007ce0eb1 --- /dev/null +++ b/docs/frontend-system/building-apps/05-installing-plugins.md @@ -0,0 +1,71 @@ +--- +id: installing-plugins +title: Installing Plugins +sidebar_label: Installing Plugins +description: How to install frontend plugins in a Backstage app +--- + +Frontend plugins are installed in your Backstage app by adding them as dependencies of your app package. Most of the time this is all you need to do, as the app will automatically discover and install the plugin. + +## Install a plugin package + +To install a plugin, add it as a dependency to your app package. For example, to install the catalog plugin: + +```bash title="From your Backstage root directory" +yarn --cwd packages/app add @backstage/plugin-catalog +``` + +If your app is set up with [feature discovery](#feature-discovery), the plugin will be automatically detected and installed in the app. No additional code changes are needed. + +## Feature discovery + +Feature discovery lets the app automatically discover and install plugins from the dependencies of your app package. This is enabled by setting `app.packages` to `all` in your `app-config.yaml`: + +```yaml title="app-config.yaml" +app: + packages: all +``` + +This is the recommended setup and is the default for all new Backstage apps. With this enabled, any plugin that is added as a dependency of your app package will be automatically discovered and installed. You can use include or exclude filters to control which packages are discovered: + +```yaml title="app-config.yaml" +app: + packages: + include: + - '@backstage/plugin-catalog' + - '@backstage/plugin-scaffolder' +``` + +```yaml title="app-config.yaml" +app: + packages: + exclude: + - '@backstage/plugin-catalog' +``` + +Feature discovery requires that your app is built using the `@backstage/cli`, which is the default for all Backstage apps. Note that you do not need to exclude packages that you also install manually in code, since plugin instances are deduplicated by the app. + +For more details on how feature discovery works under the hood, see the [Feature Discovery](../architecture/10-app.md#feature-discovery) architecture documentation. + +## Manual installation + +If your app does not have [feature discovery](#feature-discovery) enabled, or if you need more control over the plugin installation, you can install plugins manually. This is done by importing the plugin and passing it to `createApp`: + +```tsx title="packages/app/src/App.tsx" +import { createApp } from '@backstage/frontend-defaults'; +import catalogPlugin from '@backstage/plugin-catalog/alpha'; + +const app = createApp({ + features: [catalogPlugin], +}); + +export default app.createRoot(); +``` + +Manual installation may also be necessary if you need to control the ordering of plugins, for example when customizing route priorities. Since manually installed plugins are deduplicated against automatically discovered ones, you can safely install a plugin both manually and through feature discovery without causing conflicts. + +If you need to use a 3rd-party plugin that does not yet support the new frontend system, you can use the conversion utilities from `@backstage/core-compat-api` to wrap it. See [Converting 3rd-party Plugins](./06-plugin-conversion.md) for details. + +## Configuring installed plugins + +Once a plugin is installed, you can configure its extensions through the `app.extensions` section of your `app-config.yaml`. See [Configuring Extensions](./02-configuring-extensions.md) for details. diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index d9acf76e15..861f454280 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -110,6 +110,21 @@ What we've built here is a very common type of plugin. It's a top-level tool tha We have also provided external access to our route reference by passing it to the plugin `routes` option. This makes it possible for app integrators to bind an external link from a different plugin to our plugin page. You can read more about how this works in the [External Route References](../architecture/36-routes.md#external-route-references) section. +## Running a dev server + +Each frontend plugin package has a `dev/` folder that is used as the entry point when you run `yarn start`. This is a convenient way to run your plugin in isolation during development. The `@backstage/frontend-dev-utils` package provides a `createDevApp` helper that sets up a minimal app for this purpose: + +```tsx title="in dev/index.ts" +import { createDevApp } from '@backstage/frontend-dev-utils'; +import myPlugin from '../src'; + +createDevApp({ features: [myPlugin] }); +``` + +This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. You can also use `bindRoutes` to wire up any external routes that your plugin depends on. + +The dev setup is started by running `yarn start` in the plugin directory, which uses the `backstage-cli package start` command. It sets up a local development server with hot reloading, just like a full app. + ## Utility APIs Another type of extensions that is commonly used are [Utility APIs](../utility-apis/01-index.md). They can encapsulate shared pieces of functionality of your plugin, for example an API client for a backend service. You can optionally export your Utility API for other plugins to use, or allow integrators to replace the implementation of your Utility API with their own. For details on how to define and provide your own Utility API in your plugin, see the section on [creating Utility APIs](../utility-apis/02-creating.md). diff --git a/docs/getting-started/homepage--old.md b/docs/getting-started/homepage--old.md new file mode 100644 index 0000000000..98021d76a2 --- /dev/null +++ b/docs/getting-started/homepage--old.md @@ -0,0 +1,201 @@ +--- +id: homepage--old +title: Backstage homepage - Setup and Customization (Old Frontend System) +description: Documentation on setting up and customizing Backstage homepage +--- + +::::info +This documentation is for Backstage apps that still use the old frontend +system. If your app uses the new frontend system, read the +[current homepage guide](./homepage.md) instead. +:::: + +## Homepage + +Having a good Backstage homepage can significantly improve the discoverability +of the platform. You want your users to find all the things they need right +from the homepage and never have to remember direct URLs in Backstage. The +[Home plugin](https://github.com/backstage/backstage/tree/master/plugins/home) +introduces a system for composing a homepage for Backstage in order to surface +relevant info and provide convenient shortcuts for common tasks. It's designed +with composability in mind with an open ecosystem that allows anyone to +contribute with any component, to be included in any homepage. + +For App Integrators, the system is designed to be composable to give total +freedom in designing a Homepage that suits the needs of the organization. From +the perspective of a Component Developer who wishes to contribute with building +blocks to be included in Homepages, there's a convenient interface for bundling +the different parts and exporting them with both error boundary and lazy +loading handled under the surface. + +At the end of this tutorial, you can expect: + +- Your Backstage app to have a dedicated homepage instead of Software Catalog. +- Understand the composability of homepage and how to start customizing it for + your own organization. + +### Prerequisites + +Before we begin, make sure + +- You have created your own standalone Backstage app using + [`@backstage/create-app`](./index.md#1-create-your-backstage-app) and not + using a fork of the [backstage](https://github.com/backstage/backstage) + repository. +- You do not have an existing homepage, and by default you are redirected to + Software Catalog when you open Backstage. + +Now, let's get started by installing the home plugin and creating a simple +homepage for your Backstage app. + +## Setup + +### 1. Install the plugin + +```bash title="From your Backstage root directory" +yarn --cwd packages/app add @backstage/plugin-home +``` + +### 2. Create a new HomePage component + +Inside your `packages/app` directory, create a new file where our new homepage +component is going to live. Create `packages/app/src/components/home/HomePage.tsx` +with the following initial code + +```tsx +export const HomePage = () => ( + /* We will shortly compose a pretty homepage here. */ +

Welcome to Backstage!

+); +``` + +### 3. Update router for the root `/` route + +If you don't have a homepage already, most likely you have a redirect setup to +use the Catalog homepage as a homepage. + +Inside your `packages/app/src/App.tsx`, look for + +```tsx title="packages/app/src/App.tsx" +const routes = ( + + + {/* ... */} + +); +``` + +Let's replace the `` line and use the new component we created in the +previous step as the new homepage. + +```tsx title="packages/app/src/App.tsx" +/* highlight-add-start */ +import { HomepageCompositionRoot } from '@backstage/plugin-home'; +import { HomePage } from './components/home/HomePage'; +/* highlight-add-end */ + +const routes = ( + + {/* highlight-remove-next-line */} + + {/* highlight-add-start */} + }> + + + {/* highlight-add-end */} + {/* ... */} + +); +``` + +### 4. Update sidebar items + +Let's update the route for "Home" in the Backstage sidebar to point to the new +homepage. We'll also add a Sidebar item to quickly open Catalog. + +| Before | After | +| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| ![Sidebar without Catalog](../assets/getting-started/sidebar-without-catalog.png) | ![Sidebar with Catalog](../assets/getting-started/sidebar-with-catalog.png) | + +The code for the Backstage sidebar is most likely inside your +[`packages/app-legacy/src/components/Root/Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app-legacy/src/components/Root/Root.tsx). + +Let's make the following changes + +```tsx title="packages/app/src/components/Root/Root.tsx" +/* highlight-add-next-line */ +import CategoryIcon from '@material-ui/icons/Category'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + {/* ... */} + }> + {/* Global nav, not org-specific */} + {/* highlight-remove-next-line */} + + {/* highlight-add-start */} + + + {/* highlight-add-end */} + + + + + {/* End global nav */} + + {/* ... */} + + + +); +``` + +That's it! You should now have _(although slightly boring)_ a homepage! + + + +![Screenshot of a blank homepage](../assets/getting-started/simple-homepage.png) + +In the next steps, we will make it interesting and useful! + +#### Use the default template + +There is a default homepage template +([storybook link](https://backstage.io/storybook/?path=/story/plugins-home-templates--default-template)) +which we will use to set up our homepage. Checkout the +[blog post announcement](https://backstage.io/blog/2022/01/25/backstage-homepage-templates) +about the Backstage homepage templates for more information. + + + +#### Composing your homepage + +Composing a homepage is no different from creating a regular React Component, +i.e. the App Integrator is free to include whatever content they like. However, +there are components developed with the homepage in mind. If you are looking +for components to use when composing your homepage, you can take a look at the +[collection of Homepage components](https://backstage.io/storybook?path=/story/plugins-home-components) +in storybook. If you don't find a component that suits your needs but want to +contribute, check the +[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing). + +> If you want to use one of the available homepage templates you can find the +> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates) +> in the storybook under the "Home" plugin. And if you would like to contribute +> a template, please see the +> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing) + +```tsx +import Grid from '@material-ui/core/Grid'; +import { HomePageCompanyLogo } from '@backstage/plugin-home'; + +export const HomePage = () => ( + + + + + +); +``` diff --git a/docs/getting-started/homepage.md b/docs/getting-started/homepage.md index 05e422610a..22761eb6ed 100644 --- a/docs/getting-started/homepage.md +++ b/docs/getting-started/homepage.md @@ -4,6 +4,13 @@ title: Backstage homepage - Setup and Customization description: Documentation on setting up and customizing Backstage homepage --- +::::info +This documentation is written for the new frontend system, which is the default +in new Backstage apps. If your Backstage app still uses the old frontend system, +read the [old frontend system version of this guide](./homepage--old.md) +instead. +:::: + ## Homepage Having a good Backstage homepage can significantly improve the discoverability of the platform. You want your users to find all the things they need right from the homepage and never have to remember direct URLs in Backstage. The [Home plugin](https://github.com/backstage/backstage/tree/master/plugins/home) introduces a system for composing a homepage for Backstage in order to surface relevant info and provide convenient shortcuts for common tasks. It's designed with composability in mind with an open ecosystem that allows anyone to contribute with any component, to be included in any homepage. @@ -24,39 +31,17 @@ Before we begin, make sure Now, let's get started by installing the home plugin and creating a simple homepage for your Backstage app. -## Setup Methods +## Setup -There are two ways to set up the home plugin, depending on which frontend system your Backstage app uses: - -1. **New Frontend System (Recommended)** - For apps using the new plugin system with extensions and blueprints -2. **Legacy Frontend System** - For existing apps using the legacy plugin architecture - -### New Frontend System Setup - -If your Backstage app uses the [new frontend system](../frontend-system/index.md), follow these steps: - -#### 1. Install the plugin +### 1. Install the plugin ```bash title="From your Backstage root directory" yarn --cwd packages/app add @backstage/plugin-home ``` -#### 2. Add the plugin to your app configuration +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](../frontend-system/building-apps/05-installing-plugins.md). -Update your `packages/app/src/app.tsx` to include the home plugin: - -```tsx title="packages/app/src/app.tsx" -import homePlugin from '@backstage/plugin-home/alpha'; - -const app = createApp({ - features: [ - // ... other plugins - homePlugin, - ], -}); -``` - -#### 3. Configure the homepage as your root route +### 2. Configure the homepage as your root route By default, the homepage will be available at `/home`. To make it your app's landing page at `/`, add this configuration to your `app-config.yaml`: @@ -70,7 +55,7 @@ app: The plugin will automatically add a "Home" navigation item to your sidebar and provide a basic homepage layout. -#### 4. Optional: Enable visit tracking +### 3. Optional: Enable visit tracking Visit tracking is an optional feature that allows users to see their recently visited and most visited pages on the homepage. This feature is **disabled by default** to give you control over what data is collected and stored. @@ -88,156 +73,12 @@ app: - app-root-element:home/visit-listener: true ``` -#### 5. Customizing your homepage +### 4. Customizing your homepage -The New Frontend System provides powerful customization options: +The home plugin provides powerful customization options: **Custom Homepage Layouts**: Use the `HomePageLayoutBlueprint` from `@backstage/plugin-home-react/alpha` to create custom homepage layouts with your own design and widget arrangements. A layout receives the installed widgets and is responsible for rendering them. If no custom layout is installed, the plugin provides a built-in default. **Adding Homepage Widgets**: Register custom widgets using the `HomePageWidgetBlueprint` from the `@backstage/plugin-home-react/alpha` package. For detailed instructions on creating custom layouts, registering widgets, and advanced configuration options, see the [Home plugin documentation](https://github.com/backstage/backstage/tree/master/plugins/home#readme). - -### Legacy Frontend System Setup - -If your Backstage app uses the legacy frontend system, follow these steps: - -#### 1. Install the plugin - -```bash title="From your Backstage root directory" -yarn --cwd packages/app add @backstage/plugin-home -``` - -#### 2. Create a new HomePage component - -Inside your `packages/app` directory, create a new file where our new homepage component is going to live. Create `packages/app/src/components/home/HomePage.tsx` with the following initial code - -```tsx -export const HomePage = () => ( - /* We will shortly compose a pretty homepage here. */ -

Welcome to Backstage!

-); -``` - -#### 3. Update router for the root `/` route - -If you don't have a homepage already, most likely you have a redirect setup to use the Catalog homepage as a homepage. - -Inside your `packages/app/src/App.tsx`, look for - -```tsx title="packages/app/src/App.tsx" -const routes = ( - - - {/* ... */} - -); -``` - -Let's replace the `` line and use the new component we created in the previous step as the new homepage. - -```tsx title="packages/app/src/App.tsx" -/* highlight-add-start */ -import { HomepageCompositionRoot } from '@backstage/plugin-home'; -import { HomePage } from './components/home/HomePage'; -/* highlight-add-end */ - -const routes = ( - - {/* highlight-remove-next-line */} - - {/* highlight-add-start */} - }> - - - {/* highlight-add-end */} - {/* ... */} - -); -``` - -#### 4. Update sidebar items - -Let's update the route for "Home" in the Backstage sidebar to point to the new homepage. We'll also add a Sidebar item to quickly open Catalog. - -| Before | After | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| ![Sidebar without Catalog](../assets/getting-started/sidebar-without-catalog.png) | ![Sidebar with Catalog](../assets/getting-started/sidebar-with-catalog.png) | - -The code for the Backstage sidebar is most likely inside your [`packages/app-legacy/src/components/Root/Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app-legacy/src/components/Root/Root.tsx). - -Let's make the following changes - -```tsx title="packages/app/src/components/Root/Root.tsx" -/* highlight-add-next-line */ -import CategoryIcon from '@material-ui/icons/Category'; - -export const Root = ({ children }: PropsWithChildren<{}>) => ( - - - - {/* ... */} - }> - {/* Global nav, not org-specific */} - {/* highlight-remove-next-line */} - - {/* highlight-add-start */} - - - {/* highlight-add-end */} - - - - - {/* End global nav */} - - {/* ... */} - - - -); -``` - -That's it! You should now have _(although slightly boring)_ a homepage! - - - -![Screenshot of a blank homepage](../assets/getting-started/simple-homepage.png) - -In the next steps, we will make it interesting and useful! - -### Use the default template - -There is a default homepage template ([storybook link](https://backstage.io/storybook/?path=/story/plugins-home-templates--default-template)) which we will use to set up our homepage. Checkout the [blog post announcement](https://backstage.io/blog/2022/01/25/backstage-homepage-templates) about the Backstage homepage templates for more information. - - - -### Composing your homepage - -Composing a homepage is no different from creating a regular React Component, -i.e. the App Integrator is free to include whatever content they like. However, -there are components developed with the homepage in mind. If you are looking -for components to use when composing your homepage, you can take a look at the -[collection of Homepage components](https://backstage.io/storybook?path=/story/plugins-home-components) -in storybook. If you don't find a component that suits your needs but want to -contribute, check the -[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing). - -> If you want to use one of the available homepage templates you can find the -> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates) -> in the storybook under the "Home" plugin. And if you would like to contribute -> a template, please see the -> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing) - -```tsx -import Grid from '@material-ui/core/Grid'; -import { HomePageCompanyLogo } from '@backstage/plugin-home'; - -export const HomePage = () => ( - - - - - -); -``` diff --git a/docs/integrations/azure-blobStorage/discovery.md b/docs/integrations/azure-blobStorage/discovery.md index f0953815c2..f0019a5c01 100644 --- a/docs/integrations/azure-blobStorage/discovery.md +++ b/docs/integrations/azure-blobStorage/discovery.md @@ -61,7 +61,7 @@ the Azure catalog plugin: yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-azure ``` -Then updated your backend by adding the following line: +Then update your backend by adding the following line: ```ts title="packages/backend/src/index.ts" backend.add(import('@backstage/plugin-catalog-backend')); diff --git a/docs/integrations/azure/discovery.md b/docs/integrations/azure/discovery.md index 0eafc938a2..67d70c7f6d 100644 --- a/docs/integrations/azure/discovery.md +++ b/docs/integrations/azure/discovery.md @@ -97,7 +97,7 @@ the Azure catalog plugin: yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-azure ``` -Then updated your backend by adding the following line: +Then update your backend by adding the following line: ```ts title="packages/backend/src/index.ts" backend.add(import('@backstage/plugin-catalog-backend')); diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md index 92f3f9c12a..7bd93f38b3 100644 --- a/docs/integrations/azure/org.md +++ b/docs/integrations/azure/org.md @@ -41,7 +41,7 @@ catalog: For large organizations, this plugin can take a long time, so be careful setting low frequency / timeouts and importing a large amount of users / groups for the first try. ::: -Finally, updated your backend by adding the following line: +Finally, update your backend by adding the following line: ```ts title="packages/backend/src/index.ts" backend.add(import('@backstage/plugin-catalog-backend')); diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 8adc81184a..22a843009f 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -40,7 +40,7 @@ catalog: timeout: PT15M ``` -Finally, updated your backend by adding the following line: +Finally, update your backend by adding the following line: ```ts title="packages/backend/src/index.ts" backend.add(import('@backstage/plugin-catalog-backend')); diff --git a/docs/integrations/okta/org.md b/docs/integrations/okta/org.md new file mode 100644 index 0000000000..63769e7c85 --- /dev/null +++ b/docs/integrations/okta/org.md @@ -0,0 +1,23 @@ +--- +id: org +title: Okta Organizational Data +sidebar_label: Org Data +description: Ingesting organizational data from Okta into Backstage +--- + +The Backstage catalog can be set up to ingest organizational data — users and +groups — directly from Okta. The result is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your Okta organization. + +This integration is provided by the community-maintained +[`@roadiehq/catalog-backend-module-okta`](https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/backend/catalog-backend-module-okta) +plugin, owned and maintained by [Roadie](https://roadie.io/). + +## Installation and configuration + +For setup instructions, including authentication options (API token and OAuth +2.0), user/group filtering, custom naming strategies, and entity transformers, +see the +[plugin documentation maintained by Roadie](https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/backend/catalog-backend-module-okta). diff --git a/docs/landing-page/doc-landing-page.md b/docs/landing-page/doc-landing-page.md index 98a0ad9fb9..b7cc44c696 100644 --- a/docs/landing-page/doc-landing-page.md +++ b/docs/landing-page/doc-landing-page.md @@ -115,7 +115,7 @@ description: Documentation landing page. diff --git a/docs/overview/threat-model.md b/docs/overview/threat-model.md index 60f22a374c..5c33c5f8e6 100644 --- a/docs/overview/threat-model.md +++ b/docs/overview/threat-model.md @@ -30,7 +30,7 @@ This section assumes that you are using the Backstage is primarily designed to be deployed in a protected environment rather than being exposed to the public internet. From a confidentiality and integrity perspective, Backstage is designed to protect against unauthorized access to data and to ensure that data is not tampered with. However, Backstage does not provide more than rudimentary protection against denial of service attacks, and it is the responsibility of the operator to ensure that the Backstage deployment is protected against such attacks. A common and recommended way to protect a Backstage deployment from unauthorized access is to deploy it behind an authenticating proxy such as AWS’s ALB, GCP’s IAP, or Cloudflare Access. -Users that are signed-in in to Backstage generally have full access to all information and actions. If more fine-grained control is required, the [permissions system](../permissions/overview.md) should be enabled and configured to restrict access as necessary. +Users that are signed in to Backstage generally have full access to all information and actions. If more fine-grained control is required, the [permissions system](../permissions/overview.md) should be enabled and configured to restrict access as necessary. An operator is responsible for protecting the integrity of configuration files as it may otherwise be possible to introduce vulnerable configurations, as well as the confidentiality of configured secrets related to Backstage as these typically include authentication details to third party systems. diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index 2c7826a149..c7bc468632 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -294,6 +294,20 @@ will be set up that listens to the protocol, host and port set by `app.baseUrl` in the configuration. If needed it is also possible to override the listening options through the `app.listen` configuration. +For frontend plugin packages using the new frontend system, the recommended way to +set up the `dev/index` entry point is to use the `createDevApp` helper from +`@backstage/frontend-dev-utils`. It creates and renders a minimal Backstage app +with your plugin loaded: + +```tsx title="in dev/index.ts" +import { createDevApp } from '@backstage/frontend-dev-utils'; +import myPlugin from '../src'; + +createDevApp({ features: [myPlugin] }); +``` + +For the legacy frontend system, the `@backstage/dev-utils` package provides equivalent helpers. + The frontend development bundling is currently based on [Webpack](https://webpack.js.org/) and [Webpack Dev Server](https://webpack.js.org/configuration/dev-server/). The diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 085175c958..2ed0e91891 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -204,6 +204,129 @@ Options: --module-federation Build a package as a module federation remote. Applies to frontend plugin packages only. ``` +## package bundle + +:::caution Experimental +This command is experimental and may receive breaking changes in future releases +without a deprecation period. It is hidden from the main `--help` output. +::: + +Bundle a plugin for dynamic loading. This creates a self-contained plugin +package that can be deployed independently and loaded dynamically by a Backstage +application. Supports both backend and frontend plugins. + +Unlike regular builds, the bundle command: + +- Creates a fully self-contained plugin deliverable + +- Produces module federation assets (frontend) or includes plugin dependencies in the plugin's private `node_modules`, building and packing (with `yarn pack`) the local `workspace:^` dependencies first (backend). +- Generates a config schema from plugin-related packages only. +- Validates that the plugin exports valid dynamic loading entry points (backend only) + +### Usage + +```bash +# Bundle the current package (output: ./bundle/) +yarn backstage-cli package bundle + +# Bundle to a specific directory (output: ../dynamic-plugins//) +yarn backstage-cli package bundle --output-destination ../dynamic-plugins + +# Override the bundle subdirectory name +yarn backstage-cli package bundle --output-name my-plugin-bundle + +# Clean output before bundling +yarn backstage-cli package bundle --clean + +# Skip building for the plugin and its local dependencies +yarn backstage-cli package bundle --no-build + +# Skip dependency installation and entrypoint validation +yarn backstage-cli package bundle --no-install + +# Stream detailed output from build, pack, and install steps +yarn backstage-cli package bundle --verbose + +# Use a pre-built dist workspace for batch bundling. +# First, create the workspace with: +# backstage-cli build-workspace [packages...] --alwaysPack +# Then pass as --pre-packed-dir: +yarn backstage-cli package bundle --pre-packed-dir ../dist-workspace +``` + +### Options + +```text +Usage: backstage-cli package bundle [options] + +Bundle a plugin for dynamic loading + +Options: + --output-destination Directory in which the bundle subdirectory is created. + Defaults to the current package directory. + --output-name Name of the bundle subdirectory. Defaults to "bundle" when + output stays in the package directory, or to the mangled + package name (e.g. myorg-plugin-foo) when + --output-destination is specified. + --clean Clean the output directory before bundling + --no-build Skip building packages (assumes they are already built) + --no-install Skip dependency installation and entrypoint validation. + --verbose Stream detailed output from internal steps (build, pack, + install) to the console. Without this flag, output is + captured to per-step log files and only shown on error. + --pre-packed-dir Path to a pre-built dist workspace (from + build-workspace --alwaysPack). Skips local dependency + packing and uses pre-packed packages directly. For frontend + plugins, this also enables yarn.lock generation for SBOM. +``` + +### Output Contract + +The bundle output is a directory that can be deployed as a standalone unit. +Consumers of the bundle (such as `@backstage/backend-dynamic-feature-service` +or `@backstage/frontend-dynamic-feature-loader`) can rely on the following +guarantees: + +**All bundles:** + +- A `package.json` at the bundle root with entry points configured for dynamic + loading. The `backstage.role` and `files` fields are preserved from the source package. +- A `dist/` directory containing the built plugin code. +- A `dist/.config-schema.json` file (when any config schemas apply) containing + gathered schemas from the plugin, its local workspace dependencies, and + third-party dependencies. Schemas from unrelated Backstage packages are excluded. +- No `scripts` or `devDependencies` in `package.json`. + +**Backend plugins** (`backend-plugin`, `backend-plugin-module`): + +- A `node_modules/` directory with all production dependencies (including local + workspace dependencies), pinned to their exact versions from the source lockfile. +- `bundleDependencies` is set to `true` in `package.json`. + +**Frontend plugins** (`frontend-plugin`, `frontend-plugin-module`): + +- `main` points to `dist/remoteEntry.js` (the Module Federation remote entry). +- `types` points to `dist/@mf-types/index.d.ts` when type declarations are + available. +- No embedded `node_modules/` directory. + +### Environment Variables + +The bundle command supports the same environment variables as the Backstage yarn plugin +for resolving `backstage:^` version specifiers: + +- `BACKSTAGE_MANIFEST_FILE`: Path to a local manifest file (for offline usage) +- `BACKSTAGE_VERSIONS_BASE_URL`: Custom base URL for fetching release manifests + +### Supported Package Roles + +The bundle command supports packages with the following roles: + +- `backend-plugin` +- `backend-plugin-module` +- `frontend-plugin` +- `frontend-plugin-module` + ## package lint Lint a package. In addition to the default `eslint` behavior, this command will @@ -414,9 +537,17 @@ package. This essentially calls `yarn pack` in each included package and unpacks the resulting archive in the target `workspace-dir`. ```text -Usage: backstage-cli build-workspace [options] +Usage: backstage-cli build-workspace [options] [packages...] + +Options: + --alwaysPack Force workspace output to be a result of running `yarn pack` on + each package (warning: very slow) ``` +When `--alwaysPack` is used, the output directory can be passed to +`backstage-cli package bundle --pre-packed-dir` to speed up batch bundling of +multiple plugins from the same monorepo. + ## create-github-app Creates a GitHub App in your GitHub organization. This is an alternative to diff --git a/microsite/README.md b/microsite/README.md index 62a28fb6a3..42b39acfa4 100644 --- a/microsite/README.md +++ b/microsite/README.md @@ -13,6 +13,7 @@ This website was created with [Docusaurus](https://docusaurus.io/). - [Editing Content](#editing-content) - [Adding Content](#adding-content) - [Full Documentation](#full-documentation) +- [How to patch the stable docs](#patching-the-docs) # Getting Started @@ -240,3 +241,7 @@ Full documentation can be found on the [Docusaurus website](https://docusaurus.i A feedback widget is provided by the `docusaurus-pushfeedback` plugin, which connects to the https://pushfeedback.com service. Styling of the button is configured in `microsite/src/theme/customTheme.scss`, while the plugin itself is configured in `microsite/docusaurus.config.js`. Feedback submissions are connected to an account owned by @Rugvip and are available on request, reach out to @Rugvip on [Discord](https://discord.gg/backstage-687207715902193673). + +# Patching the docs + +To patch the documentation for the stable version, you can submit a PR to the correct `patch/v` branch. For example, if `1.48.0` is the stable version, you would submit a fix to the branch `patch/v1.48.0`. Once your fix is in, you can trigger the `deploy_microsite` GH action (or wait until the next run on master) and you should see the fix on the stable version at https://backstage.io/docs. diff --git a/microsite/blog/2020-03-18-what-is-backstage.mdx b/microsite/blog/2020-03-18-what-is-backstage.mdx index caac489ca1..2fd04768d6 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.mdx +++ b/microsite/blog/2020-03-18-what-is-backstage.mdx @@ -36,7 +36,7 @@ Our internal installation of Backstage has over 100 different integrations — w 3. Centralised technical documentation 4. Review performance of your team’s mobile features -These are just a few examples. Expect us to continue providing examples of how Backstage is used inside Spotify while we build out more end-2-end use-cases in the open. +These are just a few examples. Expect us to continue providing examples of how Backstage is used inside Spotify while we build out more end-to-end use-cases in the open. ### 1. Creating a new microservice diff --git a/microsite/blog/2023-04-26-kubecon-eu-2023.mdx b/microsite/blog/2023-04-26-kubecon-eu-2023.mdx index 8f369c2d14..983e546bd2 100644 --- a/microsite/blog/2023-04-26-kubecon-eu-2023.mdx +++ b/microsite/blog/2023-04-26-kubecon-eu-2023.mdx @@ -1,7 +1,7 @@ --- title: 'ICYMI: KubeCon EU 2023' author: Bailey Brooks, Spotify -authorURL: http://github.com/bailey +authorURL: https://github.com/bailey authorImageURL: https://pbs.twimg.com/profile_images/1477424303192694785/qCfN6XWW_400x400.jpg --- diff --git a/microsite/blog/2023-09-29-chicago-traiding-company-adopter-spotlight.mdx b/microsite/blog/2023-09-29-chicago-traiding-company-adopter-spotlight.mdx index 169c907e40..d7297ad145 100644 --- a/microsite/blog/2023-09-29-chicago-traiding-company-adopter-spotlight.mdx +++ b/microsite/blog/2023-09-29-chicago-traiding-company-adopter-spotlight.mdx @@ -21,19 +21,19 @@ After exploring several service routes from managed to in-house/owned, CTC opted The CTC DevOps team created a small special interest group to champion the developer portal build and quickly delved into the big issues impacting their developer experience. From there, the team began creating tasks and templates within Backstage. -To start, Scott worked with end-user teams outside DevOps early in the process of documenting migration to the new DevOps Kubernetes platform services. When Scott wrote the onboarding documentation, he made it a point to pair with a developer on another team that would be using it. This approach provided him with a quality sounding board, great instantaneous feedback, and a sort of "beta developer" to test V1 documentation clarity., A few weeks later, another colleague on the same end-user team followed the documentation and she didn't reach out to Scott at all. +To start, Scott worked with end-user teams outside DevOps early in the process of documenting migration to the new DevOps Kubernetes platform services. When Scott wrote the onboarding documentation, he made it a point to pair with a developer on another team that would be using it. This approach provided him with a quality sounding board, great instantaneous feedback, and a sort of "beta developer" to test V1 documentation clarity. A few weeks later, another colleague on the same end-user team followed the documentation and she didn't reach out to Scott at all. After this early win, the team was ready for broad distribution. Partnering with the senior leadership team, Scott's team began onboarding teams to the new DevOps Kubernetes platform using the templates his team created in Backstage. At CTC, their Backstage instance is set up to automatically scan for deployments in Kubernetes; so if you're in Kubernetes, you're onboarded to Backstage. -"Backstage made onboarding [to the new DevOps platform] not scary,"" Scott said. "Because now — all of a sudden — you have this recipe for how to do it, you don't have to jump through these hoops, the templates are there and ready for you. You choose what you want based on these templates we have available, and you're off and running on your own."" +"Backstage made onboarding [to the new DevOps platform] not scary," Scott said. "Because now — all of a sudden — you have this recipe for how to do it, you don't have to jump through these hoops, the templates are there and ready for you. You choose what you want based on these templates we have available, and you're off and running on your own." Scott's team monitored progress against their onboarding goals partially by how often the DevOps team was getting pinged for support and found that it has made support smoother. They can easily refer people to templates and repo standards instead of making them create something ad hoc. -"It's really been cool seeing how that progressed. I even have a few teams that didn't talk to DevOps at all. They used a [template] and they have a service ready. We were actually pretty excited about that, because that means now we have a scalable tool able to onboard others onto our platform."" said Scott. +"It's really been cool seeing how that progressed. I even have a few teams that didn't talk to DevOps at all. They used a [template] and they have a service ready. We were actually pretty excited about that, because that means now we have a scalable tool able to onboard others onto our platform," said Scott. -Another exciting win for CTC was template additions generated outside the initial toolkit, which has eased the burden substantially for the DevOps team. They were able to create new templates to deploy against best practices such as a template for creating a brand-new Terraform module and Git repo or — Scott's favorite — ​​a template that creates a Java repository along with Jenkins jobs for CI and the Flux CD config for deployment. +Another exciting win for CTC was template additions generated outside the initial toolkit, which has eased the burden substantially for the DevOps team. They were able to create new templates to deploy against best practices such as a template for creating a brand-new Terraform module and Git repo or — Scott's favorite — a template that creates a Java repository along with Jenkins jobs for CI and the Flux CD config for deployment. -"The template builds and auto-deploys a Docker image, so if you make a change to your mainline branch, then it will automatically build a new Docker image and deploy it to a Kubernetes cluster."" Scott said. "So within basically 10 minutes of you filling out a form, you actually have something deployed out to a Kubernetes cluster."" +"The template builds and auto-deploys a Docker image, so if you make a change to your mainline branch, then it will automatically build a new Docker image and deploy it to a Kubernetes cluster," Scott said. "So within basically 10 minutes of you filling out a form, you actually have something deployed out to a Kubernetes cluster." This process eased flows for both end-user devs and DevOps teams overall. @@ -49,11 +49,11 @@ For CTC, the journey with Backstage has just begun. They're now looking to drive When asked about advice he has for other Backstage adopters, Scott talked about the DevOps team's comms strategy and ensuring end-user developers had pathways to provide feedback. In addition to the focus on templates for the new services, having both a dedicated Slack channel and open-door communication with the DevOps team helped reduce onboarding friction to the DevOps Kubernetes platform. -Outside of that, he believes the approach to documentation is paramount. "Write your documentation from the perspective of, 'Hey, you have this task assigned to you to write a template, here are the exact steps you have to follow.'"" Scott said. "Think of it as a recipe. Removing your familiarity bias will help make this tool more useful for less familiar teams."" +Outside of that, he believes the approach to documentation is paramount. "Write your documentation from the perspective of, 'Hey, you have this task assigned to you to write a template, here are the exact steps you have to follow'," Scott said. "Think of it as a recipe. Removing your familiarity bias will help make this tool more useful for less familiar teams." Finally, don't be afraid to step outside the core use cases when it comes to the Backstage framework and finding solutions to meet your needs. -"Backstage gives you a lot of really easy to use features right out of the box. And one of the great things about open source is you can look at the code and see exactly what the behavior is and work within that behavior. But we've also developed our own processes for custom tasks because — at the end of the day — our platform has some very custom aspects to it."" Scott said. +"Backstage gives you a lot of really easy to use features right out of the box. And one of the great things about open source is you can look at the code and see exactly what the behavior is and work within that behavior. But we've also developed our own processes for custom tasks because — at the end of the day — our platform has some very custom aspects to it," Scott said. Interested in more stories from Backstage adopters? Check out these recent posts from [Stash](https://backstage.io/blog/2023/07/08/stash-adopter-post) and [Expedia Group](https://backstage.io/blog/2023/08/17/expedia-proof-of-value-metrics-2). diff --git a/microsite/blog/2025-12-30-backstage-wrapped-2025.mdx b/microsite/blog/2025-12-30-backstage-wrapped-2025.mdx index 0f8bdd21cf..d4b66c6a70 100644 --- a/microsite/blog/2025-12-30-backstage-wrapped-2025.mdx +++ b/microsite/blog/2025-12-30-backstage-wrapped-2025.mdx @@ -43,7 +43,7 @@ Couldn't spot your name in the videos? Here's one more chance — every contribu ## Mature for a five-year-old -![Backstage community stats: 3.4k+ adopters, 1,8k contributors, 15k Discord members, 7k project forks, 68k total contributions, 250+ open source plugins, 31k stars](assets/2025-12-30/wrapped2025-stats.png) +![Backstage community stats: 3.4k+ adopters, 1.8k contributors, 15k Discord members, 7k project forks, 68k total contributions, 250+ open source plugins, 31k stars](assets/2025-12-30/wrapped2025-stats.png) _The growing Backstage ecosystem, by the numbers_ Earlier this year, the Backstage project [celebrated its 5th birthday][bday] on the main stage at [KubeCon + CloudNativeCon in London][kcon-eu]. diff --git a/microsite/data/plugins/rulehub-policy-catalog.yaml b/microsite/data/plugins/rulehub-policy-catalog.yaml new file mode 100644 index 0000000000..3474ebe3db --- /dev/null +++ b/microsite/data/plugins/rulehub-policy-catalog.yaml @@ -0,0 +1,10 @@ +title: RuleHub Policy Catalog +author: Rulehub +authorUrl: https://github.com/rulehub +category: Security +description: Backstage frontend plugin that displays a policy/compliance catalog from a static JSON index. +documentation: https://github.com/rulehub/rulehub-backstage-plugin#readme +iconUrl: https://github.com/rulehub.png +npmPackageName: '@rulehub/rulehub-backstage-plugin' +addedDate: '2026-03-06' +status: active diff --git a/microsite/docusaurus.config.ts b/microsite/docusaurus.config.ts index ce75a2e063..718a337b01 100644 --- a/microsite/docusaurus.config.ts +++ b/microsite/docusaurus.config.ts @@ -450,6 +450,13 @@ const config: Config = { ], }, image: 'img/sharing-opengraph.png', + metadata: [ + { + name: 'description', + content: + 'Backstage is an open source developer portal framework that centralizes your software catalog, unifies infrastructure tools, and helps teams ship high-quality code faster.', + }, + ], footer: { links: [ { diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index 7e4e41591b..ec4585b407 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -113,6 +113,14 @@ export default { description: 'Features powering the core of Backstage.', }, [ + sidebarElementWithIndex( + { + label: 'AI', + description: + 'Features in Backstage you can leverage with your AI tools.', + }, + ['ai/mcp-actions', 'ai/well-known-actions'], + ), sidebarElementWithIndex( { label: 'Auth and Identity', @@ -399,6 +407,7 @@ export default { 'integrations/google-cloud-storage/locations', ]), sidebarElementWithIndex({ label: 'LDAP' }, ['integrations/ldap/org']), + sidebarElementWithIndex({ label: 'Okta' }, ['integrations/okta/org']), ], ), sidebarElementWithIndex( @@ -551,15 +560,15 @@ export default { ), sidebarElementWithIndex( { - label: 'New Frontend System', - description: 'New frontend system components and architecture.', + label: 'Frontend System', + description: 'Frontend system components and architecture.', }, [ 'frontend-system/index', sidebarElementWithIndex( { label: 'Architecture', - description: 'Architecture of the new frontend system.', + description: 'Architecture of the frontend system.', differentiator: 'frontend-system/', }, [ @@ -597,6 +606,7 @@ export default { }, [ 'frontend-system/building-apps/index', + 'frontend-system/building-apps/installing-plugins', 'frontend-system/building-apps/configuring-extensions', 'frontend-system/building-apps/built-in-extensions', 'frontend-system/building-apps/plugin-conversion', diff --git a/package.json b/package.json index 4b217e8af9..c89dcefce6 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "plugins/*" ], "scripts": { - "build-storybook": "storybook build --output-dir dist-storybook", - "build-storybook:chromatic": "STORYBOOK_STORY_SET=chromatic storybook build --stats-json --output-dir dist-storybook", "build:all": "backstage-cli repo build --all", "build:api-docs": "LANG=en_EN yarn build:api-reports --docs --exclude 'plugins/@(api-docs|api-docs-module-protoc-gen-doc|app-visualizer|catalog-graph|catalog-import|catalog-unprocessed-entities|config-schema|example-todo-list|example-todo-list-backend)'", "build:api-reports": "yarn build:api-reports:only --tsc", @@ -30,6 +28,8 @@ "build:backend": "yarn workspace example-backend build", "build:knip-reports": "backstage-repo-tools knip-reports", "build:plugins-report": "node ./scripts/build-plugins-report", + "build-storybook": "storybook build --output-dir dist-storybook", + "build-storybook:chromatic": "STORYBOOK_STORY_SET=chromatic storybook build --stats-json --output-dir dist-storybook", "clean": "backstage-cli repo clean", "create-plugin": "echo \"use 'yarn new' instead\"", "dev": "echo \"use 'yarn start' instead\"", @@ -52,10 +52,10 @@ "snyk:test": "npx snyk test --yarn-workspaces --strict-out-of-sync=false", "snyk:test:package": "yarn snyk:test --include", "start": "backstage-cli repo start", - "start-backend": "echo \"Use 'yarn start example-backend' instead\"", "start:docker": "docker compose -f docker-compose.deps.yml up --wait && BACKSTAGE_ENV=docker yarn start", "start:legacy": "yarn start example-app-legacy example-backend", "start:microsite": "cd microsite/ && yarn start", + "start-backend": "echo \"Use 'yarn start example-backend' instead\"", "storybook": "storybook dev -p 6006", "sync-issue-templates": "node ./.github/ISSUE_TEMPLATE/sync.js", "techdocs-cli": "node scripts/techdocs-cli.js", @@ -125,6 +125,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:*", + "@backstage/cli-defaults": "workspace:*", "@backstage/codemods": "workspace:*", "@backstage/create-app": "workspace:*", "@backstage/e2e-test-utils": "workspace:*", @@ -163,7 +164,7 @@ "jest": "^30", "js-yaml": "^4.1.1", "jsdom": "^27", - "lint-staged": "^15.0.0", + "lint-staged": "^16.0.0", "madge": "^8.0.0", "minimist": "^1.2.5", "node-gyp": "^10.0.0", @@ -171,7 +172,7 @@ "semver": "^7.5.3", "shx": "^0.4.0", "sloc": "^0.3.1", - "sort-package-json": "^2.8.0", + "sort-package-json": "^3.0.0", "storybook": "^10.3.0-alpha.1", "ts-morph": "^24.0.0", "typedoc": "^0.28.0", diff --git a/packages/app-legacy/package.json b/packages/app-legacy/package.json index d659a7896a..4dc968be00 100644 --- a/packages/app-legacy/package.json +++ b/packages/app-legacy/package.json @@ -92,7 +92,7 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", - "@types/jquery": "^3.3.34", + "@types/jquery": "^4.0.0", "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", diff --git a/packages/app/app-config.yaml b/packages/app/app-config.yaml deleted file mode 100644 index 37893722f7..0000000000 --- a/packages/app/app-config.yaml +++ /dev/null @@ -1,269 +0,0 @@ -app: - packages: 'all' # ✨ - - routes: - bindings: - catalog.viewTechDoc: techdocs.docRoot - org.catalogIndex: catalog.catalogIndex - - pluginOverrides: - - match: - pluginId: pages - info: - description: 'This description was overridden in packages/app/app-config.yaml' - - match: - pluginId: /^catalog(-.*)?$/ - info: - ownerEntityRefs: [cubic-belugas] - - match: - packageName: '@backstage/plugin-scaffolder' - info: - ownerEntityRefs: [cubic-belugas] - - extensions: - # set availableLanguages example - - api:app/app-language: - config: - availableLanguages: ['en', 'es', 'fr', 'de', 'ja'] - defaultLanguage: 'en' - - entity-card:org/members-list: - config: - showAggregateMembersToggle: true - initialRelationAggregation: aggregated - - entity-card:org/ownership: - config: - ownedKinds: ['Component', 'API', 'System'] - - # - apis.plugin.graphiql.browse.gitlab: true - # - graphiql-endpoint:graphiql/gitlab: true - - - nav-item:search: false - - nav-item:user-settings: false - - nav-item:catalog - - nav-item:api-docs - - nav-item:scaffolder - - nav-item:app-visualizer - - # Pages - - page:catalog/entity: - config: - showNavItemIcons: true - # default content order for all groups, can be 'title' or 'natural' - # defaultContentOrder: title - groups: - # placing a tab at the beginning - - overview: - title: Overview - # example disabling a default group - # - development: false - # example overriding a default group title - - documentation: - title: Docs - icon: docs - # example aliasing a group - # aliases: - # - docs - - deployment: - title: Deployments - # example adding a new group - - custom: - title: Custom - - # Entity page cards - - entity-card:catalog/about: - config: - type: info - - entity-card:catalog/labels - - entity-card:catalog/links: - config: - filter: - kind: component - metadata.links: - $exists: true - # filter: kind:component has:links - type: info - # - entity-card:linguist/languages - - entity-card:catalog-graph/relations: - config: - height: 300 - - entity-card:api-docs/has-apis - - entity-card:api-docs/consumed-apis - - entity-card:api-docs/provided-apis - - entity-card:api-docs/providing-components - - entity-card:api-docs/consuming-components - # Org Plugin - - entity-card:org/group-profile - - entity-card:org/members-list - - entity-card:org/ownership - - entity-card:org/user-profile: - config: - maxRelations: 5 - hideIcons: true - # - entity-card:azure-devops/readme - - # Entity page contents - - entity-content:catalog/overview - - entity-content:api-docs/definition - - entity-content:api-docs/apis: - config: - # example overriding the default group - group: documentation - icon: kind:api - - entity-content:techdocs: - config: - icon: techdocs - - entity-content:kubernetes/kubernetes: - config: - # example disassociating from the default group - group: false - # - entity-content:azure-devops/pipelines - # - entity-content:azure-devops/pull-requests - # - entity-content:azure-devops/git-tags - - # Enable the catalog-unprocessed-entities tab in devtools - - devtools-content:catalog-unprocessed-entities: true - # Disable the catalog-unprocessed-entities element outside devtools - - page:catalog-unprocessed-entities: false - - # scmAuthExtension: >- - # createScmAuthExtension({ - # id: 'apis.scmAuth.addons.ghe', - # factory({ bind }) { - # bind.scmAuthAddon({ - # baseUrl: 'https://github.spotify.net', - # api: githubAuthApiRef, - # }) - # } - # }) - - # externalRouteRefs: - # bind(catalogPlugin.externalRoutes, { - # createComponent: scaffolderPlugin.routes.root, - # viewTechDoc: techdocsPlugin.routes.docRoot, - # createFromTemplate: scaffolderPlugin.routes.selectedTemplate, - # }); - # bind(apiDocsPlugin.externalRoutes, { - # registerApi: catalogImportPlugin.routes.importPage, - # }); - # bind(scaffolderPlugin.externalRoutes, { - # registerComponent: catalogImportPlugin.routes.importPage, - # viewTechDoc: techdocsPlugin.routes.docRoot, - # }); - # bind(orgPlugin.externalRoutes, { - # catalogIndex: catalogPlugin.routes.catalogIndex, - # }); - - # extensions: - # - plugin.catalog: - # config: - # externalRoutes: - # createComponent: plugin.scaffolder.page - # viewTechDoc: plugin.techdocs.docRootPage - # createFromTemplate: plugin.scaffolder.templatePage - # - graphiql.page: - # config: - # path: / - # - apis.auth.providers.github: - # config: - # provider: ghe - - # - core.signInPage: - # props: - # provider: - # id: google - # title: Google - # message: Sign In using Google - # apiRef: googleAuthApiRef # ??? - - # - core.nav: - # config: - # logo: assets/logo.png - # layout: - # - label: Search - # icon: search - # to: /search - # items: - # - point: search - # - type: divider - # - label: Menu - # icon: menu - # items: - # - label: Home - # icon: home - # to: /catalog - # - label: Create... - # icon: create - # to: /create - # - type: divider - # - type: scroll-wrapper - # items: - # - label: Tech Radar - # icon: map - # to: /tech-radar - # - label: GraphiQL - # icon: graphiql - # to: /graphiql - # - type: divider - # - point: shortcuts - # - type: space - # - type: divider - # - label: Settings - # icon: avatar - # to: /settings - # items: - # - point: settings - # - core.nav/search: '@backstage/plugin-search#SidebarSearchModal' - # - core.nav/shortcuts: - # use: '@backstage/plugin-shortcuts#Shortcuts' - # config: - # allowExternalLinks: true - # - core.nav/settings: '@backstage/plugin-user-settings#SidebarSettings' - - # - core.pages.index: - # at: - # point: core.routes - # config: - # path: / - # use: 'react-router-dom#Navigate' - # config: - # to: /catalog - - # - scaffolder.page: - # config: - # groups: - # - title: Recommended - # filter: - # entity.metadata.tags: recommended - # - scaffolder.page/fields: '@backstage/plugin-scaffolder#LowerCaseValuePickerFieldExtension' - # - scaffolder.page/layout: '@backstage/plugin-scaffolder#TwoColumnLayout' - - # - search.page/content: 'app#customSearchPage' # custom search page from somewhere - - # - user-settings.page.routes.advanced: - # at: - # point: user-settings.page/routes - # config: - # title: Advanced - # path: /advanced - # use: '@backstage/plugin-user-settings#AdvancedSettings' - - # - entity.card.orphanWarning - # - entity.card.processingErrors - # - entity.card.about - # - entity.card.catalogGraph - # - entity.card.pagerDuty - # - entity.card.links - # - entity.card.labels - # - entity.card.githubInsightsLanguages - # - entity.card.githubInsightsReleases - # - entity.card.githubInsightsReadme: - # config: - # maxHeight: 350 # Throwing this config in to have an example, but in practice rely on default - # - entity.card.subcomponentsCard - # - entity.card.userProfile - # - entity.card.ownership - # - entity.card.likeDislikeRatings - # - entity.content.dependsOnComponents - # - entity.content.codeInsights - # - entity.content.todo - # - entity.content.feedbackResponse diff --git a/packages/app/package.json b/packages/app/package.json index 0d2ab9c536..22d7436dc6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,7 +19,7 @@ "build": "backstage-cli package build", "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "start": "backstage-cli package start --config ../../app-config.yaml --config app-config.yaml", + "start": "backstage-cli package start", "test": "backstage-cli package test" }, "browserslist": { @@ -98,7 +98,7 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", - "@types/jquery": "^3.3.34", + "@types/jquery": "^4.0.0", "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", diff --git a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts index dbce47bea4..3f600dcbbc 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actions/actionsServiceFactory.test.ts @@ -66,6 +66,7 @@ describe('actionsServiceFactory', () => { const mockActionsDefinition: ActionsServiceAction = { description: 'my mock description', id: 'my-plugin:test', + pluginId: 'my-plugin', name: 'testy', title: 'Test', schema: { @@ -755,6 +756,7 @@ describe('actionsServiceFactory', () => { { description: 'Test', id: 'plugin-with-action:with-validation', + pluginId: 'plugin-with-action', name: 'with-validation', schema: { input: { diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 8cda4c675f..f4d9acca06 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -107,6 +107,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { name: action.name, title: action.title, description: action.description, + pluginId: this.metadata.getId(), attributes: { // Inspired by the @modelcontextprotocol/sdk defaults for the hints. // https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812 diff --git a/packages/backend-dynamic-feature-service/README.md b/packages/backend-dynamic-feature-service/README.md index 8122810f96..914df15f65 100644 --- a/packages/backend-dynamic-feature-service/README.md +++ b/packages/backend-dynamic-feature-service/README.md @@ -97,7 +97,7 @@ Since this service only handles loading, you would choose a packaging approach b **When to use:** Plugin only uses dependencies that are already provided by the main Backstage application. -**How to apply:** +**How to use:** ```bash cd my-backstage-plugin @@ -117,7 +117,7 @@ tar -xzf package.tgz -C /path/to/dynamic-plugins-root/my-backstage-plugin --stri **When to use:** Plugin has private dependencies not available in the main Backstage application. -**How to apply:** +**How to use:** ```bash # Package the plugin @@ -136,35 +136,47 @@ yarn install # Installs all the plugin's dependencies **Example scenario:** Plugin needs `axios@1.4.0` which isn't available in the main application. -### 3. Custom packaging CLI tool +### 3. Dedicated bundling CLI command -**When to use:** When you want to produce self-contained dynamic plugin packages that can be directly extracted without any post-action, and systematically use the core `@backstage` dependencies provided by the Backstage application. +**When to use:** -**What a packaging CLI needs to do:** +- When you want to produce self-contained dynamic plugin packages that can be directly extracted and loaded without any post-action, +- especially when your plugin depends on other packages in the same monorepo. -1. **Analyze plugin dependencies** - Identify which are Backstage core vs private dependencies -2. **Create distribution package** - Generate a new directory with modified structure: - - Move `@backstage/*` packages from `dependencies` to `peerDependencies` in package.json - - Keep only private dependencies in the `dependencies` section - - Keep the built JavaScript code unchanged - - Include only the filtered private dependencies in `node_modules` -3. **Result** - A self-contained package that uses the main app's `@backstage/*` packages but includes its own private dependencies +**How to use:** -**Benefits:** - -- Systematic use of main application's `@backstage/*` packages (no version conflicts), enabling the future implementation of `@backstage` dependency version checking at start time -- Self-contained packages with only necessary private dependencies -- No post-installation steps required (extract and run) -- Consistent dependency structure across all dynamic plugins -- Production-ready distribution format - -**Example implementation:** The [`@red-hat-developer-hub/cli`](https://github.com/redhat-developer/rhdh-cli) tool implements this approach: +The [`backstage-cli package bundle`](../cli/cli-report.md) command automates the required steps. Run it from within a plugin directory: ```bash cd my-backstage-plugin -npx @red-hat-developer-hub/cli@latest plugin export -# Creates a self-contained package with embedded dependencies in the `/dist-dynamic` sub-folder - -# Deploy the generated package -cp -r dist-dynamic /path/to/dynamic-plugins-root/my-backstage-plugin +yarn backstage-cli package bundle --output-destination /path/to/dynamic-plugins-root +# Creates a self-contained bundle in the /path/to/dynamic-plugins-root/my-backstage-plugin/ sub-folder ``` + +**Batch bundling:** When bundling many plugins from the same monorepo, use `--pre-packed-dir` to avoid redundant work: + +```bash +# First, build a shared dist workspace +backstage-cli build-workspace dist-workspace --alwaysPack ...plugin-packages + +# Then bundle each plugin using the pre-packed output +cd plugins/my-backstage-plugin +backstage-cli package bundle --pre-packed-dir ../../dist-workspace +``` + +See the full list of options in the [CLI reference](../cli/cli-report.md). + +**What the command does:** + +1. **Builds the plugin** — Produces CJS output for the backend plugin and its transitively-required monorepo packages (`*-node` or `*-common`) +2. **Packs local packages** — Resolves `workspace:^` and `backstage:^` dependencies on both the main plugin package and its transitively-required monorepo packages (`*-node` or `*-common`) +3. **Installs private dependencies** — Seeds a lockfile from the plugin source monorepo and prunes it, then installs a private `node_modules` containing all required dependencies +4. **Collects configuration schemas** — Gathers plugin config schemas and writes them to `dist/.config-schema.json` so they are available for validation at load time + +**Benefits:** + +- Self-contained packages with all necessary dependencies +- No post-installation steps required (extract and run) +- Consistent dependency structure across all dynamic plugins +- Automatic configuration schema collection +- Production-ready distribution format diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js new file mode 100644 index 0000000000..f3671bc0a7 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js @@ -0,0 +1,28 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +// This require resolves to the bundled proxy copy inside this plugin's +// own node_modules/@backstage/backend-plugin-api, NOT the host's copy. +var backendPluginApi = require('@backstage/backend-plugin-api'); + +// Triggers the _resolveFilename fallback: require.resolve runs from +// the bundled @backstage/backend-plugin-api whose mod.path is inside +// this plugin's node_modules. +var pkgDir = backendPluginApi.resolvePackagePath('plugin-test-backend-bundled'); + +const testBundledPlugin = backendPluginApi.createBackendPlugin({ + pluginId: "test-bundled", + register(env) { + env.registerInit({ + deps: { + logger: backendPluginApi.coreServices.rootLogger, + }, + async init({ logger }) { + logger.info("Bundled backend plugin loaded successfully"); + } + }); + } +}); + +exports.default = testBundledPlugin; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js new file mode 100644 index 0000000000..70e57950be --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js @@ -0,0 +1,22 @@ +'use strict'; + +// Proxy that simulates a bundled copy of @backstage/backend-plugin-api. +// Re-exports everything from the real workspace package, but provides a +// local resolvePackagePath so that require.resolve() runs from THIS +// file's context (mod.path inside node_modules/@backstage/backend-plugin-api). + +const nodePath = require('node:path'); +const real = require('../../../../../../../../../backend-plugin-api/src'); + +function resolvePackagePath(name) { + const args = Array.prototype.slice.call(arguments, 1); + const pkgJson = require.resolve(name + '/package.json'); + return nodePath.resolve.apply( + nodePath, + [nodePath.dirname(pkgJson)].concat(args), + ); +} + +module.exports = Object.assign({}, real, { + resolvePackagePath: resolvePackagePath, +}); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json new file mode 100644 index 0000000000..f487169afe --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json @@ -0,0 +1,6 @@ +{ + "name": "@backstage/backend-plugin-api", + "version": "0.0.0", + "description": "Proxy that re-exports the real @backstage/backend-plugin-api with a local resolvePackagePath, simulating a bundled copy.", + "main": "index.js" +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json new file mode 100644 index 0000000000..bb2dbf15a6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json @@ -0,0 +1,31 @@ +{ + "name": "plugin-test-backend-bundled-dynamic", + "version": "0.0.0", + "description": "A test dynamic backend plugin that bundles its own @backstage/backend-plugin-api.", + "backstage": { + "role": "backend-plugin", + "pluginId": "test-bundled", + "pluginPackages": [ + "plugin-test-backend-bundled" + ] + }, + "keywords": [ + "backstage", + "dynamic" + ], + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "default": "./dist/index.cjs.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs.js", + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-plugin-api": "0.0.0" + }, + "bundleDependencies": true +} diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts index ce15964c77..ac6c6ea510 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.test.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -465,6 +465,45 @@ Require stack: }); }); + it('should load a backend plugin that bundles its own @backstage/backend-plugin-api', async () => { + const dynamicPluginsLister = new DynamicPluginLister(); + const dynamicPluginsRootForBundled = resolvePath( + __dirname, + '__fixtures__/dynamic-plugins-root-for-bundled', + ); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootForBundled, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + }), + dynamicPluginsLister.feature(), + ], + }); + + expect(dynamicPluginsLister.loadedPlugins).toMatchObject([ + { + installer: { + kind: 'new', + }, + name: 'plugin-test-backend-bundled-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + ]); + }); + describe('module federation support', () => { const createRemoteProviderPlugin = ( provider: FrontendRemoteResolverProvider, diff --git a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts index 3cc640086f..8902decdfa 100644 --- a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts +++ b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts @@ -99,15 +99,21 @@ export class CommonJSModuleLoader implements ModuleLoader { ); } - // Are we trying to resolve a `package.json` from an originating module of the core backstage application - // (this is mostly done by calling `@backstage/backend-plugin-api/resolvePackagePath`). - const resolvingPackageJsonFromBackstageApplication = + // Is this a `resolvePackagePath` call from `@backstage/backend-plugin-api`? + // This covers both the host application's copy and a bundled copy living + // inside a dynamic plugin's own node_modules. + // The regex matches mod.path against the various ways the package can be resolved on disk + // (with optional subdirectory such as /src or /dist after the package name): + // - .../node_modules/@backstage/backend-plugin-api[/...] (npm-installed) + // - ...//node_modules/@backstage/backend-plugin-api[/...] (bundled) + // - .../packages/backend-plugin-api[/...] (symlinked workspace in monorepo) + const resolvingPackageJsonViaResolvePackagePath = request?.endsWith('/package.json') && - mod?.path && - !dynamicPluginsPaths.some(p => mod.path.startsWith(p)); + /[/\\](?:@backstage|packages)[/\\]backend-plugin-api(?:[/\\]|$)/.test( + mod?.path ?? '', + ); - // If not, we don't need the dedicated specific case below. - if (!resolvingPackageJsonFromBackstageApplication) { + if (!resolvingPackageJsonViaResolvePackagePath) { throw errorToThrow; } diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 729c56b2d5..0f2c220609 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -84,6 +84,7 @@ export interface ActionsService { // @alpha (undocumented) export type ActionsServiceAction = { id: string; + pluginId: string; name: string; title: string; description: string; diff --git a/packages/backend-plugin-api/src/alpha/ActionsService.ts b/packages/backend-plugin-api/src/alpha/ActionsService.ts index 528b6cea67..6e432e962f 100644 --- a/packages/backend-plugin-api/src/alpha/ActionsService.ts +++ b/packages/backend-plugin-api/src/alpha/ActionsService.ts @@ -22,6 +22,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; */ export type ActionsServiceAction = { id: string; + pluginId: string; name: string; title: string; description: string; diff --git a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts index 384a5e8e95..94d844746f 100644 --- a/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts +++ b/packages/backend-test-utils/src/alpha/services/MockActionsRegistry.ts @@ -82,6 +82,7 @@ export class MockActionsRegistry return { actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, + pluginId: 'test', name: action.name, title: action.title, description: action.description, diff --git a/packages/cli-defaults/.eslintrc.js b/packages/cli-defaults/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-defaults/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-defaults/README.md b/packages/cli-defaults/README.md new file mode 100644 index 0000000000..2066aa1e35 --- /dev/null +++ b/packages/cli-defaults/README.md @@ -0,0 +1,26 @@ +# @backstage/cli-defaults + +The default set of CLI modules for the Backstage CLI. Installing this single package provides all standard CLI commands without needing to list each module individually. + +## Included Modules + +| Module | Description | +| :----------------------------------------------------------------- | :--------------------------------------- | +| [`@backstage/cli-module-auth`](../cli-module-auth) | Authentication commands | +| [`@backstage/cli-module-build`](../cli-module-build) | Build, start, and packaging commands | +| [`@backstage/cli-module-config`](../cli-module-config) | Configuration inspection commands | +| [`@backstage/cli-module-github`](../cli-module-github) | GitHub App creation | +| [`@backstage/cli-module-info`](../cli-module-info) | Environment and dependency info | +| [`@backstage/cli-module-lint`](../cli-module-lint) | Linting commands | +| [`@backstage/cli-module-maintenance`](../cli-module-maintenance) | Repository maintenance commands | +| [`@backstage/cli-module-migrate`](../cli-module-migrate) | Migration and version management | +| [`@backstage/cli-module-new`](../cli-module-new) | Scaffolding for new plugins and packages | +| [`@backstage/cli-module-test-jest`](../cli-module-test-jest) | Jest-based testing commands | +| [`@backstage/cli-module-translations`](../cli-module-translations) | Translation management commands | + +For fine-grained control over which CLI commands are available, you can install individual modules instead. + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-defaults/catalog-info.yaml b/packages/cli-defaults/catalog-info.yaml new file mode 100644 index 0000000000..3cac883c36 --- /dev/null +++ b/packages/cli-defaults/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-defaults + title: '@backstage/cli-defaults' + description: Default set of CLI modules for the Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-defaults/package.json b/packages/cli-defaults/package.json new file mode 100644 index 0000000000..8a065fd4d7 --- /dev/null +++ b/packages/cli-defaults/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/cli-defaults", + "version": "0.0.0", + "description": "Default set of CLI modules for the Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-defaults" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/cli-module-auth": "workspace:^", + "@backstage/cli-module-build": "workspace:^", + "@backstage/cli-module-config": "workspace:^", + "@backstage/cli-module-github": "workspace:^", + "@backstage/cli-module-info": "workspace:^", + "@backstage/cli-module-lint": "workspace:^", + "@backstage/cli-module-maintenance": "workspace:^", + "@backstage/cli-module-migrate": "workspace:^", + "@backstage/cli-module-new": "workspace:^", + "@backstage/cli-module-test-jest": "workspace:^", + "@backstage/cli-module-translations": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/cli-defaults/report.api.md b/packages/cli-defaults/report.api.md new file mode 100644 index 0000000000..573f8ee454 --- /dev/null +++ b/packages/cli-defaults/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public +const _default: CliModule[]; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli-defaults/src/index.ts b/packages/cli-defaults/src/index.ts new file mode 100644 index 0000000000..dd7aed0c70 --- /dev/null +++ b/packages/cli-defaults/src/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import auth from '@backstage/cli-module-auth'; +import build from '@backstage/cli-module-build'; +import config from '@backstage/cli-module-config'; +import github from '@backstage/cli-module-github'; +import info from '@backstage/cli-module-info'; +import lint from '@backstage/cli-module-lint'; +import maintenance from '@backstage/cli-module-maintenance'; +import migrate from '@backstage/cli-module-migrate'; +import newModule from '@backstage/cli-module-new'; +import testJest from '@backstage/cli-module-test-jest'; +import translations from '@backstage/cli-module-translations'; + +/** + * The default set of CLI modules for the Backstage CLI. + * + * @public + */ +export default [ + auth, + build, + config, + github, + info, + lint, + maintenance, + migrate, + newModule, + testJest, + translations, +]; diff --git a/packages/cli-internal/.eslintrc.js b/packages/cli-internal/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-internal/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-internal/catalog-info.yaml b/packages/cli-internal/catalog-info.yaml new file mode 100644 index 0000000000..2f2a1c837a --- /dev/null +++ b/packages/cli-internal/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: internal-cli + title: '@internal/cli' +spec: + lifecycle: experimental + type: backstage-node-library + owner: tooling-maintainers diff --git a/packages/cli-internal/package.json b/packages/cli-internal/package.json new file mode 100644 index 0000000000..068735bac3 --- /dev/null +++ b/packages/cli-internal/package.json @@ -0,0 +1,30 @@ +{ + "name": "@internal/cli", + "version": "0.0.1", + "backstage": { + "role": "node-library", + "inline": true + }, + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-internal" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "lint": "backstage-cli package lint", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-node": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/cli-internal/src/InternalCliModule.ts b/packages/cli-internal/src/InternalCliModule.ts new file mode 100644 index 0000000000..213e309371 --- /dev/null +++ b/packages/cli-internal/src/InternalCliModule.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CliCommand, CliModule } from '@backstage/cli-node'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueCliModule = OpaqueType.create<{ + public: CliModule; + versions: { + readonly version: 'v1'; + readonly packageName: string; + readonly commands: Promise>; + }; +}>({ + type: '@backstage/CliModule', + versions: ['v1'], +}); diff --git a/packages/cli-internal/src/InternalCommandNode.ts b/packages/cli-internal/src/InternalCommandNode.ts new file mode 100644 index 0000000000..233e9128cb --- /dev/null +++ b/packages/cli-internal/src/InternalCommandNode.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CliCommand, CliModule } from '@backstage/cli-node'; +import { OpaqueType } from '@internal/opaque'; + +/** @internal */ +export interface CommandTreeNode { + readonly $$type: '@backstage/CommandTreeNode'; +} + +/** @internal */ +export interface CommandLeafNode { + readonly $$type: '@backstage/CommandLeafNode'; +} + +export type CommandNode = CommandTreeNode | CommandLeafNode; + +export const OpaqueCommandTreeNode = OpaqueType.create<{ + public: CommandTreeNode; + versions: { + readonly version: 'v1'; + readonly name: string; + readonly children: CommandNode[]; + }; +}>({ + type: '@backstage/CommandTreeNode', + versions: ['v1'], +}); + +export const OpaqueCommandLeafNode = OpaqueType.create<{ + public: CommandLeafNode; + versions: { + readonly version: 'v1'; + readonly name: string; + readonly command: CliCommand; + readonly module?: CliModule; + }; +}>({ + type: '@backstage/CommandLeafNode', + versions: ['v1'], +}); + +/** + * Checks whether a command node should be hidden from help output. + * Leaf nodes are hidden if they are deprecated or experimental. + * Tree nodes are hidden if all of their children are hidden. + */ +export function isCommandNodeHidden(node: CommandNode): boolean { + if (OpaqueCommandLeafNode.isType(node)) { + const { command } = OpaqueCommandLeafNode.toInternal(node); + return !!command.deprecated || !!command.experimental; + } + const { children } = OpaqueCommandTreeNode.toInternal(node); + return children.every(child => isCommandNodeHidden(child)); +} diff --git a/packages/cli/src/wiring/describeParentCallSite.ts b/packages/cli-internal/src/index.ts similarity index 61% rename from packages/cli/src/wiring/describeParentCallSite.ts rename to packages/cli-internal/src/index.ts index 35603e33b0..0e69d45032 100644 --- a/packages/cli/src/wiring/describeParentCallSite.ts +++ b/packages/cli-internal/src/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,7 +14,14 @@ * limitations under the License. */ -// Single re-export to avoid doing this import in multiple places, but still -// avoid duplicate declarations because this one is a bit tricky. -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -export { describeParentCallSite } from '../../../frontend-plugin-api/src/routing/describeParentCallSite'; +export { OpaqueCliModule } from './InternalCliModule'; +export type { + CommandNode, + CommandTreeNode, + CommandLeafNode, +} from './InternalCommandNode'; +export { + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, + isCommandNodeHidden, +} from './InternalCommandNode'; diff --git a/packages/cli-module-auth/.eslintrc.js b/packages/cli-module-auth/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-auth/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-auth/README.md b/packages/cli-module-auth/README.md new file mode 100644 index 0000000000..2a98761ac3 --- /dev/null +++ b/packages/cli-module-auth/README.md @@ -0,0 +1,19 @@ +# @backstage/cli-module-auth + +CLI module that provides authentication commands for the Backstage CLI, enabling login, logout, and credential management for Backstage instances. + +## Commands + +| Command | Description | +| :----------------- | :------------------------------------------------------- | +| `auth login` | Log in the CLI to a Backstage instance | +| `auth logout` | Log out the CLI and clear stored credentials | +| `auth show` | Show details of an authenticated instance | +| `auth list` | List authenticated instances | +| `auth print-token` | Print an access token to stdout (auto-refresh if needed) | +| `auth select` | Select the default instance | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-auth/bin/backstage-cli-module-auth b/packages/cli-module-auth/bin/backstage-cli-module-auth new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-auth/bin/backstage-cli-module-auth @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-auth/catalog-info.yaml b/packages/cli-module-auth/catalog-info.yaml new file mode 100644 index 0000000000..091bcad199 --- /dev/null +++ b/packages/cli-module-auth/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-auth + title: '@backstage/cli-module-auth' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-auth/cli-report.md b/packages/cli-module-auth/cli-report.md new file mode 100644 index 0000000000..8b4c98766f --- /dev/null +++ b/packages/cli-module-auth/cli-report.md @@ -0,0 +1,96 @@ +## CLI Report file for "@backstage/cli-module-auth" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-auth` + +``` +Usage: @backstage/cli-module-auth [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + auth [command] + help [command] +``` + +### `backstage-cli-module-auth auth` + +``` +Usage: @backstage/cli-module-auth auth [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + list + login + logout + print-token + select + show +``` + +### `backstage-cli-module-auth auth list` + +``` +Usage: @backstage/cli-module-auth auth list + +Options: + -h, --help +``` + +### `backstage-cli-module-auth auth login` + +``` +Usage: @backstage/cli-module-auth auth login + +Options: + --backend-url + --instance + --no-browser + -h, --help +``` + +### `backstage-cli-module-auth auth logout` + +``` +Usage: @backstage/cli-module-auth auth logout + +Options: + --instance + -h, --help +``` + +### `backstage-cli-module-auth auth print-token` + +``` +Usage: @backstage/cli-module-auth auth print-token + +Options: + --instance + -h, --help +``` + +### `backstage-cli-module-auth auth select` + +``` +Usage: @backstage/cli-module-auth auth select + +Options: + --instance + -h, --help +``` + +### `backstage-cli-module-auth auth show` + +``` +Usage: @backstage/cli-module-auth auth show + +Options: + --instance + -h, --help +``` diff --git a/packages/cli-module-auth/package.json b/packages/cli-module-auth/package.json new file mode 100644 index 0000000000..86bb491f7a --- /dev/null +++ b/packages/cli-module-auth/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/cli-module-auth", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-auth" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-node": "workspace:^", + "@backstage/errors": "workspace:^", + "cleye": "^2.3.0", + "cross-fetch": "^4.0.0", + "fs-extra": "^11.2.0", + "glob": "^7.1.7", + "inquirer": "^8.2.0", + "proper-lockfile": "^4.1.2", + "yaml": "^2.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0", + "@types/proper-lockfile": "^4" + }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, + "bin": "bin/backstage-cli-module-auth" +} diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md new file mode 100644 index 0000000000..510bcaabe7 --- /dev/null +++ b/packages/cli-module-auth/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-auth" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/auth/commands/list.ts b/packages/cli-module-auth/src/commands/list.ts similarity index 89% rename from packages/cli/src/modules/auth/commands/list.ts rename to packages/cli-module-auth/src/commands/list.ts index c245072c88..3da2118800 100644 --- a/packages/cli/src/modules/auth/commands/list.ts +++ b/packages/cli-module-auth/src/commands/list.ts @@ -15,10 +15,10 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { getAllInstances } from '../lib/storage'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info }, undefined, args); const { instances, selected } = await getAllInstances(); diff --git a/packages/cli/src/modules/auth/commands/login.ts b/packages/cli-module-auth/src/commands/login.ts similarity index 98% rename from packages/cli/src/modules/auth/commands/login.ts rename to packages/cli-module-auth/src/commands/login.ts index 777fcb1807..b16060f956 100644 --- a/packages/cli/src/modules/auth/commands/login.ts +++ b/packages/cli-module-auth/src/commands/login.ts @@ -15,7 +15,7 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { startCallbackServer } from '../lib/localServer'; import { spawn } from 'node:child_process'; import { challengeFromVerifier, generateVerifier } from '../lib/pkce'; @@ -37,7 +37,7 @@ import inquirer from 'inquirer'; const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { backendUrl, noBrowser, instance: instanceFlag }, } = cli( diff --git a/packages/cli/src/modules/auth/commands/logout.ts b/packages/cli-module-auth/src/commands/logout.ts similarity index 94% rename from packages/cli/src/modules/auth/commands/logout.ts rename to packages/cli-module-auth/src/commands/logout.ts index 8277ea6335..f79ed2ef35 100644 --- a/packages/cli/src/modules/auth/commands/logout.ts +++ b/packages/cli-module-auth/src/commands/logout.ts @@ -15,7 +15,7 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { getSecretStore } from '../lib/secretStore'; import { removeInstance, @@ -25,7 +25,7 @@ import { import { httpJson } from '../lib/http'; import { pickInstance } from '../lib/prompt'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { instance: instanceFlag }, } = cli( diff --git a/packages/cli/src/modules/auth/commands/printToken.ts b/packages/cli-module-auth/src/commands/printToken.ts similarity index 92% rename from packages/cli/src/modules/auth/commands/printToken.ts rename to packages/cli-module-auth/src/commands/printToken.ts index ea848a112a..39a78c1832 100644 --- a/packages/cli/src/modules/auth/commands/printToken.ts +++ b/packages/cli-module-auth/src/commands/printToken.ts @@ -15,12 +15,12 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; import { getSelectedInstance } from '../lib/storage'; import { getSecretStore } from '../lib/secretStore'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { instance: instanceFlag }, } = cli( diff --git a/packages/cli/src/modules/auth/commands/select.ts b/packages/cli-module-auth/src/commands/select.ts similarity index 90% rename from packages/cli/src/modules/auth/commands/select.ts rename to packages/cli-module-auth/src/commands/select.ts index 839fee691e..ebce23b1cb 100644 --- a/packages/cli/src/modules/auth/commands/select.ts +++ b/packages/cli-module-auth/src/commands/select.ts @@ -15,11 +15,11 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { setSelectedInstance } from '../lib/storage'; import { pickInstance } from '../lib/prompt'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { instance: instanceFlag }, } = cli( diff --git a/packages/cli/src/modules/auth/commands/show.ts b/packages/cli-module-auth/src/commands/show.ts similarity index 94% rename from packages/cli/src/modules/auth/commands/show.ts rename to packages/cli-module-auth/src/commands/show.ts index cd55430f91..e1b7c62f72 100644 --- a/packages/cli/src/modules/auth/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -15,13 +15,13 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; import { httpJson } from '../lib/http'; import { getSelectedInstance } from '../lib/storage'; import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; import { getSecretStore } from '../lib/secretStore'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { instance: instanceFlag }, } = cli( diff --git a/packages/cli/src/modules/auth/index.ts b/packages/cli-module-auth/src/index.ts similarity index 92% rename from packages/cli/src/modules/auth/index.ts rename to packages/cli-module-auth/src/index.ts index d5766219b2..fef74ec8a5 100644 --- a/packages/cli/src/modules/auth/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'auth', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['auth', 'login'], diff --git a/packages/cli/src/modules/auth/lib/auth.test.ts b/packages/cli-module-auth/src/lib/auth.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/auth.test.ts rename to packages/cli-module-auth/src/lib/auth.test.ts diff --git a/packages/cli/src/modules/auth/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/auth.ts rename to packages/cli-module-auth/src/lib/auth.ts diff --git a/packages/cli/src/modules/auth/lib/http.test.ts b/packages/cli-module-auth/src/lib/http.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/http.test.ts rename to packages/cli-module-auth/src/lib/http.test.ts diff --git a/packages/cli/src/modules/auth/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/http.ts rename to packages/cli-module-auth/src/lib/http.ts diff --git a/packages/cli/src/modules/auth/lib/localServer.test.ts b/packages/cli-module-auth/src/lib/localServer.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/localServer.test.ts rename to packages/cli-module-auth/src/lib/localServer.test.ts diff --git a/packages/cli/src/modules/auth/lib/localServer.ts b/packages/cli-module-auth/src/lib/localServer.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/localServer.ts rename to packages/cli-module-auth/src/lib/localServer.ts diff --git a/packages/cli/src/modules/auth/lib/pkce.test.ts b/packages/cli-module-auth/src/lib/pkce.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/pkce.test.ts rename to packages/cli-module-auth/src/lib/pkce.test.ts diff --git a/packages/cli/src/modules/auth/lib/pkce.ts b/packages/cli-module-auth/src/lib/pkce.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/pkce.ts rename to packages/cli-module-auth/src/lib/pkce.ts diff --git a/packages/cli/src/modules/auth/lib/prompt.test.ts b/packages/cli-module-auth/src/lib/prompt.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/prompt.test.ts rename to packages/cli-module-auth/src/lib/prompt.test.ts diff --git a/packages/cli/src/modules/auth/lib/prompt.ts b/packages/cli-module-auth/src/lib/prompt.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/prompt.ts rename to packages/cli-module-auth/src/lib/prompt.ts diff --git a/packages/cli/src/modules/auth/lib/secretStore.test.ts b/packages/cli-module-auth/src/lib/secretStore.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/secretStore.test.ts rename to packages/cli-module-auth/src/lib/secretStore.test.ts diff --git a/packages/cli/src/modules/auth/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/secretStore.ts rename to packages/cli-module-auth/src/lib/secretStore.ts diff --git a/packages/cli/src/modules/auth/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/storage.test.ts rename to packages/cli-module-auth/src/lib/storage.test.ts diff --git a/packages/cli/src/modules/auth/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts similarity index 100% rename from packages/cli/src/modules/auth/lib/storage.ts rename to packages/cli-module-auth/src/lib/storage.ts diff --git a/packages/cli-module-build/.eslintrc.js b/packages/cli-module-build/.eslintrc.js new file mode 100644 index 0000000000..a070dc55ee --- /dev/null +++ b/packages/cli-module-build/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + ignorePatterns: ['config/**', 'templates/**'], +}); diff --git a/packages/cli-module-build/README.md b/packages/cli-module-build/README.md new file mode 100644 index 0000000000..355891c907 --- /dev/null +++ b/packages/cli-module-build/README.md @@ -0,0 +1,23 @@ +# @backstage/cli-module-build + +CLI module that provides build, start, and packaging commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :----------------- | :------------------------------------------------------------------------ | +| `package build` | Build a package for production deployment or publishing | +| `package start` | Start a package for local development | +| `package clean` | Delete cache directories | +| `package prepack` | Prepares a package for packaging before publishing | +| `package postpack` | Restores the changes made by the prepack command | +| `repo build` | Build packages in the project, excluding bundled app and backend packages | +| `repo start` | Starts packages in the repo for local development | +| `repo clean` | Delete cache and output directories | +| `build-workspace` | Builds a temporary dist workspace from the provided packages | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) +- [Build System](https://backstage.io/docs/tooling/cli/build-system) diff --git a/packages/cli-module-build/bin/backstage-cli-module-build b/packages/cli-module-build/bin/backstage-cli-module-build new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-build/bin/backstage-cli-module-build @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-build/catalog-info.yaml b/packages/cli-module-build/catalog-info.yaml new file mode 100644 index 0000000000..65715cce2a --- /dev/null +++ b/packages/cli-module-build/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-build + title: '@backstage/cli-module-build' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-build/cli-report.md b/packages/cli-module-build/cli-report.md new file mode 100644 index 0000000000..1c11022dc8 --- /dev/null +++ b/packages/cli-module-build/cli-report.md @@ -0,0 +1,156 @@ +## CLI Report file for "@backstage/cli-module-build" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-build` + +``` +Usage: @backstage/cli-module-build [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + build-workspace + help [command] + package [command] + repo [command] +``` + +### `backstage-cli-module-build build-workspace` + +``` +Usage: @backstage/cli-module-build build-workspace [packages...] + +Options: + --always-pack + -h, --help +``` + +### `backstage-cli-module-build package` + +``` +Usage: @backstage/cli-module-build package [options] [command] [command] + +Options: + -h, --help + +Commands: + build + clean + help [command] + postpack + prepack + start +``` + +### `backstage-cli-module-build package build` + +``` +Usage: @backstage/cli-module-build package build + +Options: + --config + --minify + --module-federation + --role + --skip-build-dependencies + --stats + -h, --help +``` + +### `backstage-cli-module-build package clean` + +``` +Usage: @backstage/cli-module-build package clean + +Options: + -h, --help +``` + +### `backstage-cli-module-build package postpack` + +``` +Usage: @backstage/cli-module-build package postpack + +Options: + -h, --help +``` + +### `backstage-cli-module-build package prepack` + +``` +Usage: @backstage/cli-module-build package prepack + +Options: + -h, --help +``` + +### `backstage-cli-module-build package start` + +``` +Usage: @backstage/cli-module-build package start + +Options: + --check + --config + --entrypoint + --inspect + --inspect-brk + --link + --require + --role + -h, --help +``` + +### `backstage-cli-module-build repo` + +``` +Usage: @backstage/cli-module-build repo [options] [command] [command] + +Options: + -h, --help + +Commands: + build + clean + help [command] + start +``` + +### `backstage-cli-module-build repo build` + +``` +Usage: @backstage/cli-module-build repo build + +Options: + --all + --minify + --since + -h, --help +``` + +### `backstage-cli-module-build repo clean` + +``` +Usage: @backstage/cli-module-build repo clean + +Options: + -h, --help +``` + +### `backstage-cli-module-build repo start` + +``` +Usage: @backstage/cli-module-build repo start [packages...] + +Options: + --config + --inspect + --inspect-brk + --link + --plugin + --require + -h, --help +``` diff --git a/packages/cli-module-build/config/webpack-public-path.js b/packages/cli-module-build/config/webpack-public-path.js new file mode 100644 index 0000000000..3e14e96eb9 --- /dev/null +++ b/packages/cli-module-build/config/webpack-public-path.js @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This script is used to pick up and set the public path of the Webpack bundle +// at runtime. The meta tag is injected by the app build, but only present in +// the `index.html.tmpl` file. The runtime value of the meta tag is populated by +// the app backend, when it templates the final `index.html` file. +// +// This is needed for additional chunks to use the correct public path, and it +// is not possible to set the `__webpack_public_path__` variable outside of the +// build itself. The Webpack output also does not read any tags or +// similar, this seems to be the only way to dynamically configure the public +// path at runtime. +const el = document.querySelector('meta[name="backstage-public-path"]'); +const path = el?.getAttribute('content'); +if (path) { + __webpack_public_path__ = path; +} diff --git a/packages/cli-module-build/package.json b/packages/cli-module-build/package.json new file mode 100644 index 0000000000..d13fa4bac0 --- /dev/null +++ b/packages/cli-module-build/package.json @@ -0,0 +1,106 @@ +{ + "name": "@backstage/cli-module-build", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-build" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "bin": "bin/backstage-cli-module-build", + "files": [ + "dist", + "bin", + "config", + "templates" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/module-federation-common": "workspace:^", + "@manypkg/get-packages": "^1.1.3", + "@module-federation/enhanced": "^0.21.6", + "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", + "@rollup/plugin-commonjs": "^26.0.0", + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-yaml": "^4.0.0", + "@rspack/core": "^1.4.11", + "@rspack/dev-server": "^1.1.4", + "@rspack/plugin-react-refresh": "^1.4.3", + "@swc/core": "^1.15.6", + "bfj": "^9.0.2", + "buffer": "^6.0.3", + "chalk": "^4.0.0", + "chokidar": "^3.3.1", + "cleye": "^2.3.0", + "cross-spawn": "^7.0.3", + "css-loader": "^6.5.1", + "ctrlc-windows": "^2.1.0", + "esbuild-loader": "^4.0.0", + "eslint-rspack-plugin": "^4.2.1", + "eslint-webpack-plugin": "^4.2.0", + "fork-ts-checker-webpack-plugin": "^9.0.0", + "fs-extra": "^11.2.0", + "glob": "^7.1.7", + "html-webpack-plugin": "^5.6.3", + "lodash": "^4.17.21", + "mini-css-extract-plugin": "^2.4.2", + "node-stdlib-browser": "^1.3.1", + "npm-packlist": "^5.0.0", + "p-queue": "^6.6.2", + "postcss": "^8.1.0", + "postcss-import": "^16.1.0", + "process": "^0.11.10", + "raw-loader": "^4.0.2", + "react-dev-utils": "^12.0.0-next.60", + "react-refresh": "^0.18.0", + "rollup": "^4.27.3", + "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-esbuild": "^6.1.1", + "rollup-plugin-postcss": "^4.0.0", + "rollup-pluginutils": "^2.8.2", + "shell-quote": "^1.8.1", + "style-loader": "^3.3.1", + "swc-loader": "^0.2.3", + "tar": "^7.5.6", + "ts-checker-rspack-plugin": "^1.1.5", + "ts-morph": "^24.0.0", + "util": "^0.12.3", + "webpack": "~5.105.0", + "webpack-dev-server": "^5.0.0", + "yml-loader": "^2.1.0", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0", + "@types/lodash": "^4.14.151", + "@types/npm-packlist": "^3.0.0", + "@types/shell-quote": "^1.7.5" + } +} diff --git a/packages/cli-module-build/report.api.md b/packages/cli-module-build/report.api.md new file mode 100644 index 0000000000..34634dd448 --- /dev/null +++ b/packages/cli-module-build/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-build" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/build/commands/buildWorkspace.ts b/packages/cli-module-build/src/commands/buildWorkspace.ts similarity index 93% rename from packages/cli/src/modules/build/commands/buildWorkspace.ts rename to packages/cli-module-build/src/commands/buildWorkspace.ts index f9ca19962f..0abd9e5307 100644 --- a/packages/cli/src/modules/build/commands/buildWorkspace.ts +++ b/packages/cli-module-build/src/commands/buildWorkspace.ts @@ -17,9 +17,9 @@ import fs from 'fs-extra'; import { cli } from 'cleye'; import { createDistWorkspace } from '../lib/packager'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { // Normalize legacy --alwaysYarnPack alias (a genuinely different name, not // just a casing variant — type-flag handles camelCase/kebab-case natively) const normalizedArgs = args.map(a => { diff --git a/packages/cli/src/modules/build/commands/package/build/command.ts b/packages/cli-module-build/src/commands/package/build/command.ts similarity index 97% rename from packages/cli/src/modules/build/commands/package/build/command.ts rename to packages/cli-module-build/src/commands/package/build/command.ts index 7a6280299b..07c6e3a8d7 100644 --- a/packages/cli/src/modules/build/commands/package/build/command.ts +++ b/packages/cli-module-build/src/commands/package/build/command.ts @@ -29,9 +29,9 @@ import { buildFrontend } from '../../../lib/buildFrontend'; import { buildBackend } from '../../../lib/buildBackend'; import { isValidUrl } from '../../../lib/urls'; import chalk from 'chalk'; -import type { CommandContext } from '../../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { role, diff --git a/packages/cli/src/modules/build/commands/package/build/index.ts b/packages/cli-module-build/src/commands/package/build/index.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/build/index.ts rename to packages/cli-module-build/src/commands/package/build/index.ts diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore new file mode 100644 index 0000000000..eb67c88369 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/.gitignore @@ -0,0 +1 @@ +!dist* \ No newline at end of file diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json new file mode 100644 index 0000000000..5c2babcbc2 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/packages/foo-node/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/foo-node", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js new file mode 100644 index 0000000000..b76e7a9d93 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/dist/index.js @@ -0,0 +1 @@ +module.exports = { default: { $$type: '@backstage/BackendFeature' } }; diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json new file mode 100644 index 0000000000..53ea4fbdb8 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-backend/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/plugin-foo-backend", + "version": "1.0.0", + "main": "dist/index.js", + "dependencies": { + "@scope/plugin-foo-common": "1.0.0" + } +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json new file mode 100644 index 0000000000..868fad5eaf --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/backend/plugins/foo-common/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/plugin-foo-common", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json new file mode 100644 index 0000000000..1ae64d251d --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/packages/foo-web/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/foo-web", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json new file mode 100644 index 0000000000..80abb7d0b8 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo-react/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/plugin-foo-react", + "version": "1.0.0", + "main": "dist/index.js" +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/dist/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json new file mode 100644 index 0000000000..e5be9d71e5 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/dist-workspace/frontend/plugins/foo/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/plugin-foo", + "version": "1.0.0", + "main": "dist/index.js", + "dependencies": { + "@scope/plugin-foo-react": "1.0.0" + } +} diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/@mf-types/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/index.js @@ -0,0 +1 @@ + diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js new file mode 100644 index 0000000000..94f616153c --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/dist/remoteEntry.js @@ -0,0 +1 @@ +// Module Federation remote entry diff --git a/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json new file mode 100644 index 0000000000..96efb68a2f --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "@scope/plugin-foo", + "version": "1.0.0", + "backstage": { + "role": "frontend-plugin", + "pluginId": "foo" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "exports": { + ".": {}, + "./alpha": {}, + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ] + } + }, + "dependencies": { + "@backstage/catalog-model": "^1.7.6" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/cli/src/modules/build/lib/packager/index.ts b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts similarity index 84% rename from packages/cli/src/modules/build/lib/packager/index.ts rename to packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts index 75f3fdf71d..e512e4ba83 100644 --- a/packages/cli/src/modules/build/lib/packager/index.ts +++ b/packages/cli-module-build/src/commands/package/bundle/__fixtures__/packed/frontend/src/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * Copyright 2026 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createDistWorkspace } from './createDistWorkspace'; +export {}; diff --git a/packages/cli-module-build/src/commands/package/bundle/command.test.ts b/packages/cli-module-build/src/commands/package/bundle/command.test.ts new file mode 100644 index 0000000000..642e220224 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/command.test.ts @@ -0,0 +1,1126 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect", "expectPathExists"] }] */ + +import { createMockDirectory } from '@backstage/backend-test-utils'; +import chalk from 'chalk'; +import fs from 'fs-extra'; +import os from 'node:os'; +import { join as joinPath } from 'node:path'; + +import { targetPaths } from '@backstage/cli-common'; +import { + bundleCommand, + filterBundleConfigSchemas, + postProcessBundlePackageJson, +} from './command'; + +const fixturesDir = joinPath(__dirname, '__fixtures__'); + +const mockCreateDistWorkspace = jest.fn(); +const mockPackToDirectory = jest.fn(); +const mockBuildFrontend = jest.fn(); +const mockRun = jest.fn(); +const mockRunOutput = jest.fn(); +const mockListTargetPackages = jest.fn(); +const mockLoadConfigSchema = jest.fn(); +const mockCreateRequire = jest.fn(); + +// Mock external dependencies + +jest.mock('@backstage/cli-common', () => ({ + ...jest.requireActual('@backstage/cli-common'), + targetPaths: { + dir: '', + rootDir: '', + resolve: jest.fn(), + }, + run: (...args: unknown[]) => mockRun(...args), + runOutput: (...args: unknown[]) => mockRunOutput(...args), +})); + +jest.mock('../../../lib/packager', () => ({ + ...jest.requireActual('../../../lib/packager'), + createDistWorkspace: (...args: unknown[]) => mockCreateDistWorkspace(...args), + packToDirectory: (...args: unknown[]) => mockPackToDirectory(...args), +})); + +jest.mock('../../../lib/buildFrontend', () => ({ + buildFrontend: (...args: unknown[]) => mockBuildFrontend(...args), +})); + +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + PackageGraph: Object.assign(actual.PackageGraph, { + listTargetPackages: (...args: unknown[]) => + mockListTargetPackages(...args), + }), + }; +}); + +jest.mock('@backstage/config-loader', () => ({ + loadConfigSchema: (...args: unknown[]) => mockLoadConfigSchema(...args), +})); + +jest.mock('node:module', () => ({ + ...jest.requireActual('node:module'), + createRequire: (...args: unknown[]) => mockCreateRequire(...args), +})); + +// Unit tests for exported pure functions (no mocks required) + +describe('postProcessBundlePackageJson', () => { + it('clears scripts and devDependencies', () => { + const pkg: Record = { + scripts: { build: 'tsc' }, + devDependencies: { typescript: '^5.0.0' }, + }; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.scripts).toEqual({}); + expect(pkg.devDependencies).toEqual({}); + }); + + it('sets bundleDependencies for backend plugins', () => { + const pkg: Record = {}; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.bundleDependencies).toBe(true); + }); + + it('does not set bundleDependencies for frontend plugins', () => { + const pkg: Record = {}; + postProcessBundlePackageJson(pkg, '/tmp', 'frontend', undefined, {}, true); + expect(pkg.bundleDependencies).toBeUndefined(); + }); + + it('sets MF entry points for frontend plugins', () => { + const pkg: Record = { + main: './dist/index.cjs.js', + exports: { '.': './dist/index.cjs.js' }, + module: './dist/index.esm.js', + typesVersions: { '*': { '*': ['dist/index.d.ts'] } }, + }; + postProcessBundlePackageJson(pkg, '/tmp', 'frontend', undefined, {}, false); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.exports).toBeUndefined(); + expect(pkg.module).toBeUndefined(); + expect(pkg.typesVersions).toBeUndefined(); + }); + + it('does not set MF entry points for backend plugins', () => { + const pkg: Record = { + main: './dist/index.cjs.js', + }; + postProcessBundlePackageJson(pkg, '/tmp', 'backend', undefined, {}, false); + expect(pkg.main).toBe('./dist/index.cjs.js'); + }); + + it('merges root resolutions and strips patch prefix', () => { + const pkg: Record = { + resolutions: { existing: '3.0.0' }, + }; + const rootResolutions = { + 'some-dep': '1.0.0', + 'patched-dep': 'patch:patched-dep@npm%3A2.0.0#./patch', + }; + postProcessBundlePackageJson( + pkg, + '/tmp', + 'backend', + rootResolutions, + {}, + true, + ); + expect(pkg.resolutions).toEqual({ + 'some-dep': '1.0.0', + 'patched-dep': '2.0.0', + existing: '3.0.0', + }); + }); + + it('does not merge resolutions when needsDependencies is false', () => { + const pkg: Record = {}; + postProcessBundlePackageJson( + pkg, + '/tmp', + 'backend', + { dep: '1.0.0' }, + {}, + false, + ); + expect(pkg.resolutions).toBeUndefined(); + }); +}); + +describe('filterBundleConfigSchemas', () => { + const mockDir = createMockDirectory(); + + beforeEach(() => { + mockCreateRequire.mockImplementation((p: string) => + jest + .requireActual('node:module') + .createRequire(p), + ); + }); + + afterEach(() => { + mockCreateRequire.mockReset(); + }); + + function writePluginTree( + pluginPkg: Record, + deps: Record> = {}, + ) { + const content: Record = { + 'package.json': JSON.stringify(pluginPkg), + }; + for (const [name, depPkg] of Object.entries(deps)) { + content[`node_modules/${name}/package.json`] = JSON.stringify(depPkg); + } + mockDir.setContent(content); + } + + function schemas(...names: string[]) { + return names.map(n => ({ packageName: n, value: {}, path: '' })); + } + + it('includes the plugin itself', () => { + writePluginTree({ name: '@scope/my-plugin', dependencies: {} }); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@other/unrelated'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual(['@scope/my-plugin']); + }); + + it('includes third-party deps without backstage metadata', () => { + writePluginTree( + { name: '@scope/my-plugin', dependencies: { lodash: '^4.0.0' } }, + { lodash: { name: 'lodash', version: '4.17.21' } }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', 'lodash'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toContain('lodash'); + }); + + it('includes same-pluginId libraries', () => { + writePluginTree( + { + name: '@scope/my-plugin', + backstage: { role: 'backend-plugin', pluginId: 'foo' }, + dependencies: { '@scope/my-lib': '^1.0.0' }, + }, + { + '@scope/my-lib': { + name: '@scope/my-lib', + backstage: { role: 'node-library', pluginId: 'foo' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/my-lib'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual([ + '@scope/my-plugin', + '@scope/my-lib', + ]); + }); + + it('excludes library with different pluginId', () => { + writePluginTree( + { + name: '@scope/my-plugin', + backstage: { role: 'backend-plugin', pluginId: 'foo' }, + dependencies: { '@scope/other-lib': '^1.0.0' }, + }, + { + '@scope/other-lib': { + name: '@scope/other-lib', + backstage: { role: 'node-library', pluginId: 'bar' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/other-lib'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual(['@scope/my-plugin']); + }); + + it('recursively includes depended plugin/module schemas', () => { + writePluginTree( + { + name: '@scope/my-plugin', + dependencies: { '@scope/my-module': '^1.0.0' }, + }, + { + '@scope/my-module': { + name: '@scope/my-module', + backstage: { role: 'backend-plugin-module' }, + }, + }, + ); + const result = filterBundleConfigSchemas( + schemas('@scope/my-plugin', '@scope/my-module'), + mockDir.path, + ); + expect(result.map(s => s.packageName)).toEqual([ + '@scope/my-plugin', + '@scope/my-module', + ]); + }); +}); + +// Integration tests for the bundle command (require mocks) + +describe('bundle command', () => { + const mockDir = createMockDirectory(); + + const backendPluginDir = 'plugins/foo-backend'; + const backendPkg = { + name: '@scope/plugin-foo-backend', + version: '1.0.0', + backstage: { role: 'backend-plugin' }, + dependencies: {}, + }; + const backendMangledName = 'scope-plugin-foo-backend'; + + const frontendPluginDir = 'plugins/foo'; + const frontendPkg = { + name: '@scope/plugin-foo', + version: '1.0.0', + backstage: { role: 'frontend-plugin' }, + dependencies: {}, + }; + + const defaultRootPkg = { + name: 'root', + version: '1.0.0', + resolutions: { + 'some-dep': '1.0.0', + 'patched-dep': 'patch:patched-dep@npm%3A2.0.0#./patch', + }, + }; + + const defaultOpts = { + build: true, + install: true, + clean: false, + verbose: false, + outputDestination: undefined as string | undefined, + outputName: undefined as string | undefined, + prePackedDir: undefined as string | undefined, + }; + + function setupPlugin( + projectRelativeDir: string, + pkg: Record, + bundleName = 'bundle', + ) { + const pluginDir = joinPath(mockDir.path, projectRelativeDir); + const targetDir = joinPath(pluginDir, bundleName); + + mockDir.setContent({ + [joinPath(projectRelativeDir, 'package.json')]: JSON.stringify(pkg), + [joinPath(projectRelativeDir, 'yarn.lock')]: '# yarn.lock content', + 'package.json': JSON.stringify(defaultRootPkg), + 'yarn.lock': '# root yarn.lock', + 'tmp/.keep': '', + }); + targetPaths.dir = pluginDir; + targetPaths.rootDir = mockDir.path; + (targetPaths.resolve as jest.Mock).mockImplementation((...args: string[]) => + joinPath(targetPaths.dir, ...args), + ); + + return { pluginDir, targetDir, relDir: projectRelativeDir, bundleName }; + } + + function setupCreateDistWorkspaceMock( + pluginDir: string, + targets: { name: string; dir: string }[] = [], + invokeLogger = false, + ) { + const mainTarget = { name: backendPkg.name, dir: pluginDir }; + mockCreateDistWorkspace.mockImplementation(async (_pkgNames, opts) => { + if (invokeLogger && opts.logger) { + opts.logger.log(`Moving ${backendPkg.name} into dist workspace`); + opts.logger.warn('some dist workspace warning'); + } + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + opts.targetDir, + ); + return { targets: targets.length ? targets : [mainTarget] }; + }); + } + + function setupPackToDirectoryMock() { + mockPackToDirectory.mockImplementation( + async (opts: { targetDir: string; packageName: string }) => { + await fs.copy( + joinPath(fixturesDir, 'packed', 'frontend'), + opts.targetDir, + ); + const pkgPath = joinPath(opts.targetDir, 'package.json'); + const pkg = await fs.readJson(pkgPath); + pkg.name = opts.packageName; + await fs.writeJson(pkgPath, pkg); + }, + ); + } + + function setupRunMock() { + mockRun.mockImplementation( + ( + args: string[], + opts: { cwd: string; onStdout?: (d: Buffer) => void }, + ) => ({ + waitForExit: async () => { + if (!args.includes('update-lockfile')) { + await fs.ensureDir(joinPath(opts.cwd, 'node_modules')); + await fs.ensureDir(joinPath(opts.cwd, '.yarn')); + } + opts.onStdout?.(Buffer.from('mock yarn output\n')); + }, + }), + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(os, 'tmpdir').mockReturnValue(joinPath(mockDir.path, 'tmp')); + mockLoadConfigSchema.mockResolvedValue({ + serialize: () => ({ schemas: [] }), + }); + mockRunOutput.mockResolvedValue('/mock-cache'); + setupRunMock(); + mockCreateRequire.mockImplementation((path: string) => + jest.requireActual('node:module').createRequire(path), + ); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + // Clear native Node module cache for files under mockDir to prevent + // cross-test contamination when real createRequire loads dist/index.js. + const nativeModule = + jest.requireActual('node:module'); + const nativeRequire = nativeModule.createRequire(__filename); + for (const key of Object.keys(nativeRequire.cache ?? {})) { + if (key.includes(mockDir.path)) { + delete nativeRequire.cache![key]; + } + } + jest.restoreAllMocks(); + }); + + async function expectPathExists(parts: string[], exists: boolean) { + expect(await fs.pathExists(joinPath(...parts))).toBe(exists); + } + + describe('validation', () => { + it('throws when backstage.role is missing', async () => { + setupPlugin(backendPluginDir, { + name: '@scope/plugin-foo-backend', + version: '1.0.0', + }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'does not have a backstage.role defined in package.json', + ); + }); + + it('throws when role is invalid', async () => { + setupPlugin(backendPluginDir, { + ...backendPkg, + backstage: { role: 'backend' }, + }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + `only supports: ${chalk.cyan('backend-plugin')}, ${chalk.cyan( + 'backend-plugin-module', + )}, ${chalk.cyan('frontend-plugin')}, ${chalk.cyan( + 'frontend-plugin-module', + )}`, + ); + }); + + it('throws when bundled is true', async () => { + setupPlugin(backendPluginDir, { ...backendPkg, bundled: true }); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'not compatible with dynamic plugin bundling', + ); + }); + }); + + describe('backend', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = setupPlugin(backendPluginDir, backendPkg); + }); + + describe('via createDistWorkspace', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should produce backend bundle when build=true', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(pkg.bundleDependencies).toBe(true); + await expectPathExists([ctx.targetDir, '.gitignore'], true); + await expectPathExists([ctx.targetDir, '.yarnrc.yml'], true); + expect(mockCreateDistWorkspace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ buildDependencies: true }), + ); + }); + + it.each(['null', 'undefined'])( + 'should not write yarnPath when yarn config returns "%s"', + async sentinel => { + mockRunOutput.mockImplementation( + (args: string[]): Promise => { + if (args.includes('yarnPath')) { + return Promise.resolve(sentinel); + } + return Promise.resolve('/mock-cache'); + }, + ); + await bundleCommand(defaultOpts); + const yarnrc = await fs.readFile( + joinPath(ctx.targetDir, '.yarnrc.yml'), + 'utf8', + ); + expect(yarnrc).not.toContain('yarnPath'); + expect(yarnrc).toContain('nodeLinker: node-modules'); + }, + ); + + it('should write yarnPath when yarn config returns a real path', async () => { + mockRunOutput.mockImplementation((args: string[]): Promise => { + if (args.includes('yarnPath')) { + return Promise.resolve('/home/user/.yarn/releases/yarn-3.8.1.cjs'); + } + return Promise.resolve('/mock-cache'); + }); + await bundleCommand(defaultOpts); + const yarnrc = await fs.readFile( + joinPath(ctx.targetDir, '.yarnrc.yml'), + 'utf8', + ); + expect(yarnrc).toContain( + 'yarnPath: /home/user/.yarn/releases/yarn-3.8.1.cjs', + ); + }); + + it('should pass buildDependencies=false when build=false', async () => { + await bundleCommand({ ...defaultOpts, build: false }); + expect(mockCreateDistWorkspace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ buildDependencies: false }), + ); + }); + + it('should produce backend bundle for backend-plugin-module role', async () => { + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + backstage: { role: 'backend-plugin-module' }, + }); + setupCreateDistWorkspaceMock(ctx.pluginDir); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(mockCreateDistWorkspace).toHaveBeenCalled(); + expect(mockPackToDirectory).not.toHaveBeenCalled(); + }); + + it('should assemble local deps into embedded/ and clean up .yarn', async () => { + const commonRelDir = 'plugins/foo-common'; + const commonDir = joinPath(mockDir.path, commonRelDir); + + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + dependencies: { '@scope/plugin-foo-common': 'workspace:^' }, + }); + setupCreateDistWorkspaceMock(ctx.pluginDir, [ + { name: backendPkg.name, dir: ctx.pluginDir }, + { name: '@scope/plugin-foo-common', dir: commonDir }, + ]); + + await bundleCommand(defaultOpts); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + await expectPathExists( + [ctx.targetDir, 'embedded', commonRelDir, 'package.json'], + true, + ); + expect(pkg.resolutions['@scope/plugin-foo-common']).toBe( + `file:./embedded/${commonRelDir}`, + ); + await expectPathExists([ctx.targetDir, '.yarn'], false); + }); + + it('should log formatted packing output when distLogger is invoked', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir, [], true); + await bundleCommand(defaultOpts); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`Packing`), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(backendPkg.name), + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('some dist workspace warning'), + ); + }); + }); + + describe('via createDistWorkspace - failure', () => { + it('should clean up temp dir and propagate error on createDistWorkspace failure', async () => { + mockCreateDistWorkspace.mockRejectedValue(new Error('pack failed')); + const tempBase = joinPath(mockDir.path, 'tmp'); + jest.spyOn(os, 'tmpdir').mockReturnValue(tempBase); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow('pack failed'); + + const entries = await fs.readdir(tempBase).catch(() => []); + expect( + entries.filter((e: string) => e.startsWith('bundle-workspace-')), + ).toHaveLength(0); + }); + }); + + describe('via --pre-packed-dir', () => { + beforeEach(() => { + mockCreateDistWorkspace.mockClear(); + }); + + it('should copy from pre-packed dir and assemble embedded', async () => { + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + prePackedPath, + ); + + mockListTargetPackages.mockResolvedValue([ + { packageJson: { name: backendPkg.name }, dir: ctx.pluginDir }, + ]); + + await bundleCommand({ ...defaultOpts, prePackedDir: prePackedPath }); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(backendPkg.name); + expect(pkg.bundleDependencies).toBe(true); + }); + + it('should print warning when package not found in pre-packed dir', async () => { + ctx = setupPlugin(backendPluginDir, { + ...backendPkg, + dependencies: { '@scope/other-dep': 'workspace:^' }, + }); + mockListTargetPackages.mockResolvedValue([ + { + packageJson: { + name: backendPkg.name, + version: '1.0.0', + dependencies: { '@scope/other-dep': 'workspace:^' }, + }, + dir: ctx.pluginDir, + }, + { + packageJson: { name: '@scope/other-dep', version: '1.0.0' }, + dir: joinPath(mockDir.path, 'packages/other-dep'), + }, + ]); + + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + prePackedPath, + ); + + await bundleCommand({ ...defaultOpts, prePackedDir: prePackedPath }); + + expect(console.warn).toHaveBeenCalledWith( + chalk.yellow( + ` Package ${chalk.cyan( + '@scope/other-dep', + )} not found in pre-packed dir (expected at ${chalk.cyan( + 'packages/other-dep', + )})`, + ), + ); + }); + }); + + describe('lockfile and install', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should seed lockfile from monorepo root when plugin has no yarn.lock', async () => { + mockDir.setContent({ + [joinPath(backendPluginDir, 'package.json')]: + JSON.stringify(backendPkg), + 'package.json': JSON.stringify(defaultRootPkg), + 'yarn.lock': '# root yarn.lock content', + 'tmp/.keep': '', + }); + + await bundleCommand(defaultOpts); + + const lockContent = await fs.readFile( + joinPath(ctx.targetDir, 'yarn.lock'), + 'utf8', + ); + expect(lockContent).toBe('# root yarn.lock content'); + }); + + it('throws when no yarn.lock exists', async () => { + mockDir.setContent({ + [joinPath(backendPluginDir, 'package.json')]: + JSON.stringify(backendPkg), + 'package.json': JSON.stringify(defaultRootPkg), + 'tmp/.keep': '', + }); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + `Could not find a ${chalk.cyan( + 'yarn.lock', + )} file in either the plugin directory or the monorepo root (${chalk.cyan( + mockDir.path, + )})`, + ); + }); + + it('should run prune and install in the bundle dir', async () => { + await bundleCommand(defaultOpts); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining([ + 'yarn', + 'install', + '--no-immutable', + '--mode', + 'update-lockfile', + ]), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['yarn', 'install', '--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + }); + + it('throws when backend plugin has no valid BackendFeature export', async () => { + mockCreateRequire.mockImplementation(() => + Object.assign(() => ({ default: {} }), { + resolve: (id: string) => { + if (id.includes('package.json')) { + return joinPath(targetPaths.dir, 'node_modules', id); + } + throw new Error(`Cannot find module '${id}'`); + }, + }), + ); + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'Backend plugin is not valid for dynamic loading', + ); + }); + }); + + describe('--no-install', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should skip install and print warning', async () => { + await bundleCommand({ ...defaultOpts, install: false }); + + await expectPathExists([ctx.targetDir, 'package.json'], true); + await expectPathExists([ctx.targetDir, 'yarn.lock'], true); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['--mode', 'update-lockfile']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).not.toHaveBeenCalledWith( + expect.arrayContaining(['--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Skipping dependency installation'), + ); + }); + }); + + describe('options', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should remove target when clean=true', async () => { + mockDir.addContent({ + [joinPath(ctx.relDir, ctx.bundleName, 'existing-file')]: 'content', + }); + + await bundleCommand({ ...defaultOpts, clean: true }); + + await expectPathExists([ctx.targetDir, 'existing-file'], false); + await expectPathExists([ctx.targetDir, 'package.json'], true); + }); + + it('should write bundle to custom outputDestination with mangled name', async () => { + const customOutput = joinPath(mockDir.path, 'custom-output'); + await bundleCommand({ + ...defaultOpts, + outputDestination: customOutput, + }); + + await expectPathExists( + [customOutput, backendMangledName, 'package.json'], + true, + ); + }); + + it('should use "bundle" as default name when output stays in package dir', async () => { + await bundleCommand(defaultOpts); + const entries = await fs.readdir(ctx.pluginDir); + expect(entries).toContain('bundle'); + }); + + it('should use mangled package name when outputDestination is given', async () => { + const customOutput = joinPath(mockDir.path, 'custom-output'); + await bundleCommand({ + ...defaultOpts, + outputDestination: customOutput, + }); + const entries = await fs.readdir(customOutput); + expect(entries).toContain(backendMangledName); + }); + + it('should use explicit outputName when provided', async () => { + await bundleCommand({ ...defaultOpts, outputName: 'my-custom-bundle' }); + const entries = await fs.readdir(ctx.pluginDir); + expect(entries).toContain('my-custom-bundle'); + }); + + it('should pipe run output to console when verbose=true', async () => { + await bundleCommand({ ...defaultOpts, verbose: true }); + + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['yarn', 'install', '--immutable']), + expect.anything(), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('mock yarn output'), + ); + }); + + it('should write .bundle-output marker to the output directory', async () => { + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, '.bundle-output'], true); + }); + + it('should remove nested dirs that have a .bundle-output marker', async () => { + // Leave a marked directory from a previous bundle run in the source tree. + const prevBundleDir = joinPath(ctx.pluginDir, 'old-bundle'); + await fs.ensureDir(prevBundleDir); + await fs.writeFile(joinPath(prevBundleDir, '.bundle-output'), ''); + + // Simulate yarn pack pulling the stale dir into the packed output. + mockCreateDistWorkspace.mockImplementation(async (_pkgNames, opts) => { + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'backend'), + opts.targetDir, + ); + const nestedDir = joinPath(opts.targetDir, ctx.relDir, 'old-bundle'); + await fs.ensureDir(nestedDir); + await fs.writeFile(joinPath(nestedDir, 'stale-file'), ''); + return { targets: [{ name: backendPkg.name, dir: ctx.pluginDir }] }; + }); + + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, 'old-bundle'], false); + }); + + it('should not create recursive nesting when run twice without --clean', async () => { + await bundleCommand(defaultOpts); + await bundleCommand(defaultOpts); + + const entries = await fs.readdir(ctx.targetDir); + expect(entries).not.toContain('bundle'); + }); + }); + + describe('error handling', () => { + it('should propagate error and show log when pruneBundleLockfile fails', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + mockRun + .mockReturnValueOnce({ waitForExit: () => Promise.resolve() }) + .mockImplementationOnce((_args: any, opts: any) => ({ + waitForExit: async () => { + (opts?.onStdout ?? opts?.onStderr)?.( + Buffer.from('yarn prune output line 1\nline 2\n'), + ); + throw new Error('prune failed'); + }, + })); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'prune failed', + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('--- last 20 lines ---'), + ); + }); + + it('should propagate error when installBundleDependencies fails', async () => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + let callCount = 0; + mockRun.mockImplementation(() => ({ + waitForExit: () => + ++callCount === 2 + ? Promise.reject(new Error('install failed')) + : Promise.resolve(), + })); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow( + 'install failed', + ); + }); + }); + + describe('config schema', () => { + beforeEach(() => { + setupCreateDistWorkspaceMock(ctx.pluginDir); + }); + + it('should set configSchema in package.json when schemas are found', async () => { + mockLoadConfigSchema.mockResolvedValue({ + serialize: () => ({ + schemas: [ + { + packageName: backendPkg.name, + value: { + type: 'object', + properties: { foo: { type: 'string' } }, + }, + path: 'schemas/foo.json', + }, + ], + }), + }); + + await bundleCommand(defaultOpts); + + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.configSchema).toBe('dist/.config-schema.json'); + const schemaFile = await fs.readJson( + joinPath(ctx.targetDir, 'dist', '.config-schema.json'), + ); + expect(schemaFile.backstageConfigSchemaVersion).toBe(1); + expect(schemaFile.schemas).toHaveLength(1); + }); + + it('should not set configSchema when no schemas are found', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.configSchema).toBeUndefined(); + }); + }); + }); + + describe('frontend', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = setupPlugin(frontendPluginDir, frontendPkg); + setupPackToDirectoryMock(); + }); + + describe('without --pre-packed-dir', () => { + it('should produce frontend bundle when build=true', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + await expectPathExists([ctx.targetDir, '.gitignore'], true); + await expectPathExists([ctx.targetDir, '.yarnrc.yml'], false); + await expectPathExists([ctx.targetDir, 'yarn.lock'], false); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + await expectPathExists([ctx.targetDir, 'src'], false); + expect(mockBuildFrontend).toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + expect(mockCreateDistWorkspace).not.toHaveBeenCalled(); + }); + + it('should keep src/ when "files" explicitly includes it', async () => { + ctx = setupPlugin(frontendPluginDir, { + ...frontendPkg, + files: ['dist', 'src'], + }); + setupPackToDirectoryMock(); + await bundleCommand(defaultOpts); + await expectPathExists([ctx.targetDir, 'src'], true); + }); + + it('should produce frontend bundle when build=false', async () => { + await bundleCommand({ ...defaultOpts, build: false }); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + await expectPathExists([ctx.targetDir, 'yarn.lock'], false); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + expect(mockBuildFrontend).not.toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + }); + + it('should produce frontend bundle for frontend-plugin-module role', async () => { + ctx = setupPlugin(frontendPluginDir, { + ...frontendPkg, + backstage: { role: 'frontend-plugin-module' }, + }); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.name).toBe(frontendPkg.name); + expect(mockBuildFrontend).toHaveBeenCalled(); + expect(mockPackToDirectory).toHaveBeenCalled(); + expect(mockCreateDistWorkspace).not.toHaveBeenCalled(); + }); + }); + + describe('package.json post-processing', () => { + it('should apply frontend-specific and common post-processing', async () => { + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.types).toBe('./dist/@mf-types/index.d.ts'); + expect(pkg.exports).toBeUndefined(); + expect(pkg.module).toBeUndefined(); + expect(pkg.typesVersions).toBeUndefined(); + expect(pkg.dependencies).toEqual({ + '@backstage/catalog-model': '^1.7.6', + }); + expect(pkg.keywords).toEqual(['backstage']); + expect(pkg.license).toBe('Apache-2.0'); + expect(pkg.backstage).toEqual( + expect.objectContaining({ role: 'frontend-plugin' }), + ); + expect(pkg.bundleDependencies).toBeUndefined(); + expect(pkg.scripts).toEqual({}); + expect(pkg.devDependencies).toEqual({}); + }); + + it('should delete types when @mf-types/index.d.ts is absent', async () => { + mockPackToDirectory.mockImplementation( + async (opts: { targetDir: string; packageName: string }) => { + await fs.copy( + joinPath(fixturesDir, 'packed', 'frontend'), + opts.targetDir, + ); + await fs.remove( + joinPath(opts.targetDir, 'dist', '@mf-types', 'index.d.ts'), + ); + const pkgPath = joinPath(opts.targetDir, 'package.json'); + const pkg = await fs.readJson(pkgPath); + pkg.name = opts.packageName; + await fs.writeJson(pkgPath, pkg); + }, + ); + await bundleCommand(defaultOpts); + const pkg = await fs.readJson(joinPath(ctx.targetDir, 'package.json')); + expect(pkg.main).toBe('./dist/remoteEntry.js'); + expect(pkg.types).toBeUndefined(); + }); + }); + + describe('with --pre-packed-dir', () => { + beforeEach(async () => { + mockListTargetPackages.mockResolvedValue([ + { packageJson: { name: frontendPkg.name }, dir: ctx.pluginDir }, + ]); + const prePackedPath = joinPath(mockDir.path, 'pre-packed'); + await fs.copy( + joinPath(fixturesDir, 'dist-workspace', 'frontend'), + prePackedPath, + ); + mockBuildFrontend.mockImplementation(async () => { + const distDir = joinPath(ctx.pluginDir, 'dist'); + await fs.ensureDir(distDir); + await fs.writeFile( + joinPath(distDir, 'remoteEntry.js'), + '// MF remote entry', + ); + }); + }); + + it('should copy from pre-packed and prune lockfile but not install', async () => { + await bundleCommand({ + ...defaultOpts, + prePackedDir: joinPath(mockDir.path, 'pre-packed'), + }); + + await expectPathExists([ctx.targetDir, 'package.json'], true); + await expectPathExists([ctx.targetDir, 'yarn.lock'], true); + await expectPathExists([ctx.targetDir, 'node_modules'], false); + await expectPathExists([ctx.targetDir, 'dist', 'remoteEntry.js'], true); + expect(mockRun).toHaveBeenCalledWith( + expect.arrayContaining(['--mode', 'update-lockfile']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + expect(mockRun).not.toHaveBeenCalledWith( + expect.arrayContaining(['--immutable']), + expect.objectContaining({ cwd: ctx.targetDir }), + ); + }); + }); + + describe('error handling', () => { + it('should propagate error when packToDirectory fails', async () => { + mockPackToDirectory.mockRejectedValue(new Error('pack failed')); + + await expect(bundleCommand(defaultOpts)).rejects.toThrow('pack failed'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + }); + + it('should not show last 20 lines when verbose=true on failure', async () => { + mockPackToDirectory.mockRejectedValue(new Error('pack failed')); + + await expect( + bundleCommand({ ...defaultOpts, verbose: true }), + ).rejects.toThrow('pack failed'); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Full log available at'), + ); + expect(console.error).not.toHaveBeenCalledWith( + expect.stringContaining('--- last 20 lines ---'), + ); + }); + }); + }); +}); diff --git a/packages/cli-module-build/src/commands/package/bundle/command.ts b/packages/cli-module-build/src/commands/package/bundle/command.ts new file mode 100644 index 0000000000..ee88322733 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/command.ts @@ -0,0 +1,979 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; +import { run, runOutput } from '@backstage/cli-common'; +import chalk from 'chalk'; +import { cli } from 'cleye'; +import fs from 'fs-extra'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { + join as joinPath, + resolve as resolvePath, + relative as relativePath, +} from 'node:path'; + +import { loadConfigSchema } from '@backstage/config-loader'; +import { targetPaths } from '@backstage/cli-common'; +import { buildFrontend } from '../../../lib/buildFrontend'; +import { + createDistWorkspace, + packToDirectory, + resolveLocalDependencies, +} from '../../../lib/packager'; +import type { CliCommandContext } from '@backstage/cli-node'; + +interface BundleOptions { + build: boolean; + install: boolean; + clean: boolean; + verbose: boolean; + outputDestination?: string; + outputName?: string; + prePackedDir?: string; +} + +/** + * Bundle a plugin for dynamic loading. + * + * This creates a self-contained plugin bundle that can be deployed independently + * and loaded dynamically by a Backstage application. Supports both backend and + * frontend plugins. + * + * For backend plugins, `createDistWorkspace` handles building (CJS) and packing + * all local dependencies. The output is restructured so that the main plugin + * sits at the bundle root and its local dependencies live under `embedded/`. + * A lockfile is seeded, pruned, and used to install a private `node_modules`. + * + * For frontend plugins, a module federation remote build produces the final + * assets. Only the main plugin is packed into the bundle root (no `embedded/`, + * no lockfile, no `node_modules`). + * + * When `--pre-packed-dir` is provided, local dependencies are copied from a + * pre-built dist workspace instead of calling `createDistWorkspace`: + * - For backend plugins this is a performance optimization when many plugins from the same monorepo are being bundled. + * - For frontend plugins it additionally enables lockfile generation (seed + prune) for dependency tracking purposes such as SBOM generation. + * - The pre-built dist workspace is produced by + * `backstage-cli build-workspace [packages...] --alwaysPack` + * and `` is then passed as `--pre-packed-dir`. + * - The `--alwaysPack` flag is required so that `workspace:^` and `backstage:^` + * dependency specs are resolved to concrete versions in the packed output. + */ +export async function bundleCommand(opts: BundleOptions): Promise { + const pkgJsonPath = targetPaths.resolve('package.json'); + const pkg = (await fs.readJson(pkgJsonPath)) as BackstagePackageJson; + + const outputDestination = opts.outputDestination + ? resolvePath(opts.outputDestination) + : targetPaths.dir; + const mangledName = pkg.name.replace(/^@/, '').replace(/\//, '-'); + let bundleName = 'bundle'; + if (opts.outputName) { + bundleName = opts.outputName; + } else if (opts.outputDestination) { + bundleName = mangledName; + } + const target = joinPath(outputDestination, bundleName); + + const role = pkg.backstage?.role; + if (!role) { + throw new Error( + `Package ${chalk.cyan( + pkg.name, + )} does not have a backstage.role defined in package.json`, + ); + } + + const validRoles = [ + 'backend-plugin', + 'backend-plugin-module', + 'frontend-plugin', + 'frontend-plugin-module', + ]; + if (!validRoles.includes(role)) { + throw new Error( + `Package ${chalk.cyan(pkg.name)} has role ${chalk.cyan( + role, + )}, but bundle command ` + + `only supports: ${validRoles.map(r => chalk.cyan(r)).join(', ')}`, + ); + } + + const pluginType: 'frontend' | 'backend' = + role === 'frontend-plugin' || role === 'frontend-plugin-module' + ? 'frontend' + : 'backend'; + + if (pkg.bundled) { + throw new Error( + `Package ${chalk.cyan(pkg.name)} has ${chalk.cyan( + 'bundled: true', + )} which is not ` + `compatible with dynamic plugin bundling.`, + ); + } + + console.log( + chalk.blue(`Bundling ${chalk.cyan(pkg.name)} for dynamic loading...`), + ); + console.log(`${chalk.dim('Output:')} ${chalk.cyan(target)}`); + + if (opts.clean) { + console.log(chalk.blue(`Cleaning ${chalk.cyan(target)}`)); + await fs.remove(target); + } + + await fs.mkdirs(target); + + await fs.writeFile(joinPath(target, '.gitignore'), '*\n'); + await fs.writeFile(joinPath(target, '.bundle-output'), ''); + + const rootPkg = await fs.readJson( + resolvePath(targetPaths.rootDir, 'package.json'), + ); + + // Backend plugins always need embedded packages, lockfile, and node_modules. + // Frontend plugins need them only when --pre-packed-dir is provided. + const needsDependencies = pluginType === 'backend' || !!opts.prePackedDir; + + // Establish the bundle directory as its own Yarn project root so that + // the seeded yarn.lock is the one Yarn reads/writes, even when the + // output directory is inside another monorepo. + // Only needed when lockfile/install operations will run. + if (needsDependencies) { + const yarnrcLines = ['nodeLinker: node-modules']; + try { + // Include yarnPath so the same Yarn version that created the lockfile + // is used for pruning/installing -- lockfile formats differ across + // major Yarn versions (e.g. ~builtin vs optional!builtin patches). + const resolved = await runOutput(['yarn', 'config', 'get', 'yarnPath'], { + cwd: targetPaths.rootDir, + }); + const yarnPathSentinels = new Set(['undefined', 'null']); + if (resolved && !yarnPathSentinels.has(resolved)) { + yarnrcLines.push(`yarnPath: ${resolved}`); + } + } catch { + // yarnPath not configured — check if corepack manages the version instead + if (!rootPkg.packageManager) { + console.warn( + chalk.yellow( + 'No yarnPath configured and no packageManager field found. ' + + 'The Yarn version in PATH will be used for lockfile operations.', + ), + ); + } + } + + await fs.writeFile( + joinPath(target, '.yarnrc.yml'), + `${yarnrcLines.join('\n')}\n`, + ); + } + + // ── Step 0 (frontend only): Module federation build ───────────────── + if (pluginType === 'frontend' && opts.build) { + console.log(chalk.blue('Building module federation remote...')); + await buildFrontend({ + targetDir: targetPaths.dir, + configPaths: [], + writeStats: false, + isModuleFederationRemote: true, + }); + } + + const embeddedResolutions: Record = {}; + + // Detect previous bundle output directories inside the source package so + // they can be stripped from the yarn-pack result later. npm-packlist's + // basename matching on the `files` field picks up identically-named entries + // from prior bundle outputs, creating nested copies. + const bundleOutputDirs: string[] = []; + try { + const entries = await fs.readdir(targetPaths.dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if ( + await fs.pathExists( + joinPath(targetPaths.dir, entry.name, '.bundle-output'), + ) + ) { + bundleOutputDirs.push(entry.name); + } + } + } + } catch { + /* directory may not exist yet on first run */ + } + + if (needsDependencies) { + // ── Step 1: Populate embedded packages ────────────────────────────── + const embeddedDir = joinPath(target, 'embedded'); + let targets: { name: string; dir: string }[]; + + if (opts.prePackedDir) { + // ── Strategy A: Copy from pre-built dist workspace ─────────────── + // Reuses output from `backstage-cli build-workspace --alwaysPack`. + // Works for both backend (performance optimization) and frontend + // (enables lockfile generation for SBOM). + const prePackedDir = resolvePath(opts.prePackedDir); + console.log( + chalk.blue( + `Using pre-packed workspace at ${chalk.cyan(prePackedDir)}...`, + ), + ); + + const packages = await PackageGraph.listTargetPackages(); + targets = await resolveLocalDependencies([pkg.name], packages); + await fs.ensureDir(embeddedDir); + + for (const dep of targets) { + const relDir = relativePath(targetPaths.rootDir, dep.dir); + const srcDir = resolvePath(prePackedDir, relDir); + const destDir = resolvePath(embeddedDir, relDir); + if (await fs.pathExists(srcDir)) { + await fs.copy(srcDir, destDir); + } else { + console.warn( + chalk.yellow( + ` Package ${chalk.cyan(dep.name)} not found in pre-packed ` + + `dir (expected at ${chalk.cyan(relDir)})`, + ), + ); + } + } + } else { + // ── Strategy B: createDistWorkspace ────────────────────────────── + // Pack all local dependencies into a temp directory first, then + // move the result into target/embedded/. We must NOT pack directly + // into the target tree because yarn pack's basename-matching on the + // `files` field would pick up identically-named files from + // already-extracted embedded packages. + const tempWorkspaceDir = await fs.mkdtemp( + joinPath(tmpdir(), 'bundle-workspace-'), + ); + console.log(chalk.blue('Packing local dependencies...')); + + const distLog = createStepLogger( + joinPath(target, 'dist-workspace.log'), + opts.verbose, + ); + + const packingPattern = /^(?:Moving|Repacking) (.+) into dist workspace$/; + const distLogger = { + log(msg: string) { + const match = msg.match(packingPattern); + if (match) { + console.log(` ${chalk.dim('Packing')} ${chalk.cyan(match[1])}`); + } + distLog.logger.log(msg); + }, + warn(msg: string) { + console.warn(` ${chalk.yellow(msg)}`); + distLog.logger.warn(msg); + }, + }; + + try { + ({ targets } = await createDistWorkspace([pkg.name], { + targetDir: tempWorkspaceDir, + files: [], + alwaysPack: true, + buildDependencies: opts.build, + buildExcludes: opts.build ? [] : undefined, + logger: distLogger, + })); + await fs.remove(embeddedDir); + await fs.move(tempWorkspaceDir, embeddedDir); + } catch (err) { + await distLog.close(); + await showLogOnError(distLog.path, opts.verbose); + throw err; + } finally { + if (await fs.pathExists(tempWorkspaceDir)) { + await fs.remove(tempWorkspaceDir); + } + } + await distLog.close(); + await fs.remove(distLog.path); + } + + // ── Step 2: Assemble embedded packages ────────────────────────────── + const mainPluginRelDir = relativePath(targetPaths.rootDir, targetPaths.dir); + const mainPluginEmbeddedDir = resolvePath(embeddedDir, mainPluginRelDir); + + console.log( + chalk.blue( + `Moving main plugin ${chalk.cyan(pkg.name)} to bundle root...`, + ), + ); + + if (!(await fs.pathExists(mainPluginEmbeddedDir))) { + throw new Error( + `Main plugin ${chalk.cyan(pkg.name)} was not found in the ` + + `embedded workspace at ${chalk.cyan(mainPluginRelDir)}. ` + + `Ensure the pre-packed workspace includes this plugin.`, + ); + } + + const mainPluginEntries = await fs.readdir(mainPluginEmbeddedDir); + for (const entry of mainPluginEntries) { + await fs.move( + joinPath(mainPluginEmbeddedDir, entry), + joinPath(target, entry), + { overwrite: true }, + ); + } + + await fs.remove(mainPluginEmbeddedDir); + + // For frontend plugins, the pre-packed dist/ contains standard CJS + // output, not Module Federation artifacts. Overlay the MF build + // output from the source directory (produced by Step 0). + if (pluginType === 'frontend') { + const sourceDist = resolvePath(targetPaths.dir, 'dist'); + if (await fs.pathExists(sourceDist)) { + await fs.copy(sourceDist, joinPath(target, 'dist'), { + overwrite: true, + }); + } + } + + const localDeps = targets.filter(t => t.name !== pkg.name); + + for (const dep of localDeps) { + const depRelDir = relativePath(targetPaths.rootDir, dep.dir); + const depEmbeddedDir = resolvePath(embeddedDir, depRelDir); + + if (!(await fs.pathExists(depEmbeddedDir))) { + continue; + } + + embeddedResolutions[dep.name] = `file:./embedded/${depRelDir}`; + } + + if (Object.keys(embeddedResolutions).length === 0) { + await fs.remove(embeddedDir); + } + } else { + // ── Step 1b: Pack main plugin only ────────────────────────────────── + // Frontend plugins without --pre-packed-dir don't need transitive + // local deps -- just pack the main plugin directly into the bundle root. + console.log(chalk.blue(`Packing main plugin ${chalk.cyan(pkg.name)}...`)); + + const distLog = createStepLogger( + joinPath(target, 'dist-workspace.log'), + opts.verbose, + ); + + try { + await packToDirectory({ + packageDir: targetPaths.dir, + packageName: pkg.name, + targetDir: target, + logger: distLog.logger, + }); + } catch (err) { + await distLog.close(); + await showLogOnError(distLog.path, opts.verbose); + throw err; + } + await distLog.close(); + await fs.remove(distLog.path); + } + + // Remove any previous bundle output directories that were erroneously + // included by yarn pack (npm-packlist's basename matching on the `files` + // field picks up identically-named entries from prior bundle outputs). + for (const dir of bundleOutputDirs) { + const nestedPath = joinPath(target, dir); + if (await fs.pathExists(nestedPath)) { + await fs.remove(nestedPath); + } + } + + // Remove src/ directory included by yarn pack because prepack's + // rewriteEntryPoints cannot rewrite main/types away from src/ paths + // when the standard dist output files are absent (MF builds). + // Skip if the package explicitly ships src/ via the "files" field. + if (pluginType === 'frontend') { + const srcExplicitlyIncluded = (pkg.files ?? []).some( + f => f === 'src' || f.startsWith('src/'), + ); + if (!srcExplicitlyIncluded) { + const srcPath = joinPath(target, 'src'); + if (await fs.pathExists(srcPath)) { + await fs.remove(srcPath); + } + } + } + + // ── Step 3: Config schema ──────────────────────────────────────────── + console.log(chalk.blue('Filtering config schema...')); + + const schemaPath = resolvePath(target, 'dist', '.config-schema.json'); + let schemas: Array<{ packageName: string }> = []; + + if (pluginType === 'frontend' && (await fs.pathExists(schemaPath))) { + const existing = await fs.readJson(schemaPath); + schemas = existing.schemas ?? []; + } else { + const configSchema = await loadConfigSchema({ + dependencies: [], + packagePaths: ['package.json'], + }); + const serialized = configSchema.serialize() as { + schemas: typeof schemas; + }; + schemas = serialized.schemas ?? []; + } + + let schemaWritten = false; + if (schemas.length > 0) { + const filtered = filterBundleConfigSchemas(schemas, targetPaths.dir); + if (filtered.length > 0) { + await fs.ensureDir(resolvePath(target, 'dist')); + await fs.writeJson( + schemaPath, + { backstageConfigSchemaVersion: 1, schemas: filtered }, + { spaces: 2 }, + ); + schemaWritten = true; + } else { + console.log( + chalk.dim(' No config schemas found for this plugin bundle'), + ); + } + } else { + console.log(chalk.dim(' No config schemas found for this plugin bundle')); + } + + // ── Step 4: Post-process package.json ──────────────────────────────── + console.log( + chalk.blue( + `Customizing ${chalk.cyan('package.json')} for dynamic loading...`, + ), + ); + + const targetPkgPath = resolvePath(target, 'package.json'); + const targetPkg = await fs.readJson(targetPkgPath); + + postProcessBundlePackageJson( + targetPkg, + target, + pluginType, + needsDependencies ? rootPkg?.resolutions : undefined, + embeddedResolutions, + needsDependencies, + ); + + if (schemaWritten) { + targetPkg.configSchema = 'dist/.config-schema.json'; + } + + await fs.writeJson(targetPkgPath, targetPkg, { spaces: 2 }); + + // ── Step 5: Seed lockfile, prune, & install ────────────────────────── + // Runs for backend plugins (always) and frontend plugins with + // --pre-packed-dir (for SBOM lockfile generation). + if (needsDependencies) { + await seedBundleLockfile(target, targetPaths.dir, targetPaths.rootDir); + + const sourceCacheFolder = await runOutput( + ['yarn', 'config', 'get', 'cacheFolder'], + { cwd: targetPaths.rootDir }, + ); + await pruneBundleLockfile(target, opts.verbose, sourceCacheFolder); + + if (pluginType === 'backend') { + if (opts.install) { + await installBundleDependencies(target, opts.verbose); + + // Clean up .yarn directory created during install + const yarnDir = joinPath(target, '.yarn'); + if (await fs.pathExists(yarnDir)) { + await fs.remove(yarnDir); + } + + console.log(chalk.blue('Validating plugin entry points...')); + + // Temporarily patch module resolution so the plugin can resolve + // itself by name (needed by resolvePackagePath at module load + // time, e.g. for database migration paths). At runtime this is + // handled by CommonJSModuleLoader in backend-dynamic-feature-service. + const NodeModule = + require('node:module') as typeof import('node:module') & { + _resolveFilename: Function; + }; + const origResolveFilename = NodeModule._resolveFilename; + NodeModule._resolveFilename = (request: string, ...args: any[]) => { + if (request === `${targetPkg.name}/package.json`) { + return resolvePath(target, 'package.json'); + } + return origResolveFilename(request, ...args); + }; + + try { + const pluginRequire = createRequire(`${target}/package.json`); + const mainModule = pluginRequire(target); + const alphaPath = resolvePath(target, 'alpha'); + const alphaModule = (await fs.pathExists(alphaPath)) + ? pluginRequire(alphaPath) + : undefined; + + const isBackendFeature = (v: unknown) => + !!v && + (typeof v === 'object' || typeof v === 'function') && + (v as { $$type?: string }).$$type === '@backstage/BackendFeature'; + + const hasValidExport = [mainModule, alphaModule] + .filter(Boolean) + .some(m => isBackendFeature(m?.default)); + + if (!hasValidExport) { + throw new Error( + `Backend plugin is not valid for dynamic loading: ` + + `it must export a ${chalk.cyan( + 'BackendFeature', + )} as default export`, + ); + } + } finally { + NodeModule._resolveFilename = origResolveFilename; + } + } else { + console.log( + chalk.yellow( + 'Skipping dependency installation and validation. ' + + 'Run without --no-install to validate the bundle.', + ), + ); + } + } + } + + console.log(chalk.green(`Bundle created at ${chalk.cyan(target)}`)); +} + +/** + * Mutates `targetPkg` in place to prepare it for dynamic plugin loading: + * clears scripts/devDependencies, applies plugin-type-specific adjustments + * (MF entry points for frontend, bundleDependencies for backend), and merges + * root + embedded resolutions when a lockfile is involved. + */ +export function postProcessBundlePackageJson( + targetPkg: Record, + targetDir: string, + pluginType: 'frontend' | 'backend', + rootResolutions: Record | undefined, + embeddedResolutions: Record, + needsDependencies: boolean, +): void { + targetPkg.scripts = {}; + targetPkg.devDependencies = {}; + + if (pluginType === 'frontend') { + targetPkg.main = './dist/remoteEntry.js'; + + const mfTypesIndex = resolvePath( + targetDir, + 'dist', + '@mf-types', + 'index.d.ts', + ); + if (fs.pathExistsSync(mfTypesIndex)) { + targetPkg.types = './dist/@mf-types/index.d.ts'; + } else { + delete targetPkg.types; + } + + delete targetPkg.exports; + delete targetPkg.module; + delete targetPkg.typesVersions; + } else if (pluginType === 'backend') { + targetPkg.bundleDependencies = true; + } + + if (needsDependencies) { + const patchVersionPattern = /^patch:.+@npm%3A([^#]+)#/; + const stripped: Record = {}; + if (rootResolutions) { + for (const [key, value] of Object.entries(rootResolutions)) { + if (typeof value !== 'string') { + continue; + } + const patchMatch = value.match(patchVersionPattern); + stripped[key] = patchMatch ? patchMatch[1] : value; + } + } + + targetPkg.resolutions = { + ...stripped, + ...(targetPkg.resolutions as Record | undefined), + ...embeddedResolutions, + }; + } +} + +/** + * Seeds the bundle's yarn.lock from the source plugin or monorepo lockfile. + * Looks first for a local yarn.lock in the plugin directory, then falls back + * to the monorepo root. + */ +async function seedBundleLockfile( + targetDir: string, + pluginDir: string, + monorepoRoot: string, +): Promise { + let sourceYarnLock: string | undefined; + if (await fs.pathExists(joinPath(pluginDir, 'yarn.lock'))) { + sourceYarnLock = joinPath(pluginDir, 'yarn.lock'); + } else if (await fs.pathExists(joinPath(monorepoRoot, 'yarn.lock'))) { + sourceYarnLock = joinPath(monorepoRoot, 'yarn.lock'); + } + + if (!sourceYarnLock) { + throw new Error( + `Could not find a ${chalk.cyan( + 'yarn.lock', + )} file in either the plugin directory or the monorepo root (${chalk.cyan( + monorepoRoot, + )})`, + ); + } + + const isMonorepoLock = sourceYarnLock === joinPath(monorepoRoot, 'yarn.lock'); + console.log( + chalk.blue( + `Seeding bundle ${chalk.cyan('yarn.lock')} from source plugin${ + isMonorepoLock ? ' monorepo' : '' + } lockfile...`, + ), + ); + + await fs.copyFile(sourceYarnLock, resolvePath(targetDir, 'yarn.lock')); +} + +/** + * Prunes the bundle's yarn.lock to remove entries not required by the + * bundle's package.json. Runs offline to avoid network access. + */ +async function pruneBundleLockfile( + targetDir: string, + verbose: boolean, + sourceCacheFolder: string, +): Promise { + console.log( + chalk.blue( + `Pruning bundle ${chalk.cyan( + 'yarn.lock', + )} to remove unused dependencies...`, + ), + ); + + const pruneLog = createStepLogger( + joinPath(targetDir, 'lockfile-prune.log'), + verbose, + '[lockfile-prune] ', + ); + try { + await run( + ['yarn', 'install', '--no-immutable', '--mode', 'update-lockfile'], + { + cwd: targetDir, + env: { + YARN_ENABLE_GLOBAL_CACHE: 'false', + YARN_ENABLE_NETWORK: '0', + YARN_ENABLE_MIRROR: 'false', + YARN_CACHE_FOLDER: sourceCacheFolder, + }, + onStdout: pruneLog.logRunOutput('out'), + onStderr: pruneLog.logRunOutput('err'), + }, + ).waitForExit(); + } catch (err) { + await pruneLog.close(); + await showLogOnError(pruneLog.path, verbose); + throw err; + } + await pruneLog.close(); + await fs.remove(pruneLog.path); +} + +/** + * Installs the bundle's dependencies using an immutable lockfile. + * This creates the node_modules directory needed for backend plugin runtime. + */ +async function installBundleDependencies( + targetDir: string, + verbose: boolean, +): Promise { + console.log(chalk.blue('Installing private dependencies...')); + + const installLog = createStepLogger( + joinPath(targetDir, 'yarn-install.log'), + verbose, + '[yarn-install] ', + ); + try { + await run(['yarn', 'install', '--immutable'], { + cwd: targetDir, + onStdout: installLog.logRunOutput('out'), + onStderr: installLog.logRunOutput('err'), + }).waitForExit(); + } catch (err) { + await installLog.close(); + await showLogOnError(installLog.path, verbose); + throw err; + } + await installLog.close(); + await fs.remove(installLog.path); +} + +/** + * Filters config schemas to keep only those belonging to the plugin's own + * family: the plugin itself, same-pluginId libraries, third-party packages, + * and recursively any directly-depended plugin/module (wrapper scenario). + */ +export function filterBundleConfigSchemas( + schemas: { packageName: string }[], + pluginDir: string, +): { packageName: string }[] { + const PLUGIN_OR_MODULE_ROLES = new Set([ + 'backend-plugin', + 'backend-plugin-module', + 'frontend-plugin', + 'frontend-plugin-module', + ]); + const LIBRARY_ROLES = new Set([ + 'node-library', + 'common-library', + 'web-library', + ]); + + const allowed = new Set(); + const visited = new Set(); + const localRequire = createRequire(resolvePath(pluginDir, 'package.json')); + + function walk(pkg: BackstagePackageJson) { + if (visited.has(pkg.name)) { + return; + } + visited.add(pkg.name); + allowed.add(pkg.name); + + const pluginId = pkg.backstage?.pluginId; + + for (const depName of Object.keys(pkg.dependencies ?? {})) { + let depPkgPath: string; + try { + depPkgPath = localRequire.resolve(`${depName}/package.json`); + } catch { + continue; + } + + let depPkg: BackstagePackageJson; + try { + depPkg = fs.readJsonSync(depPkgPath); + } catch { + continue; + } + + const depRole = depPkg.backstage?.role; + + if (!depPkg.backstage) { + allowed.add(depName); + continue; + } + + if (depRole && PLUGIN_OR_MODULE_ROLES.has(depRole)) { + walk(depPkg); + continue; + } + + if ( + depRole && + LIBRARY_ROLES.has(depRole) && + pluginId && + depPkg.backstage?.pluginId === pluginId + ) { + allowed.add(depName); + continue; + } + } + } + + let rootPkg: BackstagePackageJson; + try { + rootPkg = fs.readJsonSync(resolvePath(pluginDir, 'package.json')); + } catch { + return []; + } + walk(rootPkg); + + return schemas.filter(s => allowed.has(s.packageName)); +} + +const ansiPattern = + // eslint-disable-next-line no-control-regex + /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g; +function stripAnsi(str: string): string { + return str.replace(ansiPattern, ''); +} + +function createStepLogger( + logFilePath: string, + verbose: boolean, + prefix?: string, +) { + const logStream = fs.createWriteStream(logFilePath); + + const writeLine = (line: string, stream: 'out' | 'err') => { + const prefixed = prefix ? `${prefix}${line}` : line; + logStream.write( + `${stream === 'err' ? '[WARN] ' : ''}${stripAnsi(prefixed)}\n`, + ); + if (verbose) { + const writer = stream === 'err' ? console.warn : console.log; + writer(chalk.dim(prefixed)); + } + }; + + const logger = { + log(msg: string) { + writeLine(msg, 'out'); + }, + warn(msg: string) { + writeLine(msg, 'err'); + }, + }; + + const logRunOutput = (stream: 'out' | 'err') => (data: Buffer) => { + if (prefix) { + for (const line of data.toString('utf8').split(/\r?\n/)) { + if (line) writeLine(line, stream); + } + } else { + logStream.write( + `${stream === 'err' ? '[WARN] ' : ''}${stripAnsi( + data.toString('utf8'), + )}`, + ); + if (verbose) { + const writer = stream === 'err' ? console.warn : console.log; + writer(chalk.dim(data.toString('utf8'))); + } + } + }; + + const close = () => new Promise(r => logStream.end(r)); + + return { logger, logRunOutput, close, path: logFilePath }; +} + +async function showLogOnError( + logFilePath: string, + verbose: boolean, +): Promise { + console.error( + chalk.red(`\nFull log available at: ${chalk.cyan(logFilePath)}`), + ); + if (!verbose) { + try { + const content = await fs.readFile(logFilePath, 'utf8'); + const tail = content.split('\n').slice(-20).join('\n'); + if (tail) { + console.error(chalk.dim('\n--- last 20 lines ---')); + console.error(tail); + } + } catch { + /* log file may not exist yet */ + } + } +} + +export default async ({ args, info }: CliCommandContext) => { + const { + flags: { + outputDestination, + outputName, + clean, + noBuild, + noInstall, + verbose, + prePackedDir, + }, + } = cli( + { + help: info, + flags: { + outputDestination: { + type: String, + description: + 'Directory in which the bundle subdirectory is created. ' + + 'Defaults to the current package directory.', + }, + outputName: { + type: String, + description: + 'Name of the bundle subdirectory. ' + + 'Defaults to "bundle" when output stays in the package directory, ' + + 'or to the mangled package name (e.g. myorg-plugin-foo) when ' + + '--output-destination is specified.', + }, + clean: { + type: Boolean, + description: 'Clean the output directory before bundling', + }, + noBuild: { + type: Boolean, + description: + 'Skip building packages (assumes they are already built)', + }, + noInstall: { + type: Boolean, + description: + 'Skip dependency installation and entrypoint validation.', + }, + verbose: { + type: Boolean, + description: + 'Stream detailed output from internal steps (build, pack, install) to the console. ' + + 'Without this flag, output is captured to per-step log files and only shown on error.', + }, + prePackedDir: { + type: String, + description: + 'Path to a pre-built dist workspace (from build-workspace --alwaysPack). ' + + 'Skips local dependency packing and uses pre-packed packages directly. ' + + 'For frontend plugins, this also enables yarn.lock generation for SBOM.', + }, + }, + }, + undefined, + args, + ); + + return bundleCommand({ + build: !noBuild, + install: !noInstall, + clean: Boolean(clean), + verbose: Boolean(verbose), + outputDestination: outputDestination ?? undefined, + outputName: outputName ?? undefined, + prePackedDir: prePackedDir ?? undefined, + }); +}; diff --git a/packages/cli-module-build/src/commands/package/bundle/index.ts b/packages/cli-module-build/src/commands/package/bundle/index.ts new file mode 100644 index 0000000000..d2d2779ce3 --- /dev/null +++ b/packages/cli-module-build/src/commands/package/bundle/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './command'; diff --git a/packages/cli/src/modules/build/commands/package/clean.ts b/packages/cli-module-build/src/commands/package/clean.ts similarity index 88% rename from packages/cli/src/modules/build/commands/package/clean.ts rename to packages/cli-module-build/src/commands/package/clean.ts index 6aa987f6c1..fffc418795 100644 --- a/packages/cli/src/modules/build/commands/package/clean.ts +++ b/packages/cli-module-build/src/commands/package/clean.ts @@ -17,9 +17,9 @@ import { cli } from 'cleye'; import fs from 'fs-extra'; import { targetPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); await fs.remove(targetPaths.resolve('dist')); await fs.remove(targetPaths.resolve('dist-types')); diff --git a/packages/cli/src/modules/build/commands/package/postpack.ts b/packages/cli-module-build/src/commands/package/postpack.ts similarity index 87% rename from packages/cli/src/modules/build/commands/package/postpack.ts rename to packages/cli-module-build/src/commands/package/postpack.ts index 232d03af6b..56856f7401 100644 --- a/packages/cli/src/modules/build/commands/package/postpack.ts +++ b/packages/cli-module-build/src/commands/package/postpack.ts @@ -17,9 +17,9 @@ import { cli } from 'cleye'; import { targetPaths } from '@backstage/cli-common'; import { revertProductionPack } from '../../lib/packager/productionPack'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); await revertProductionPack(targetPaths.dir); }; diff --git a/packages/cli/src/modules/build/commands/package/prepack.ts b/packages/cli-module-build/src/commands/package/prepack.ts similarity index 90% rename from packages/cli/src/modules/build/commands/package/prepack.ts rename to packages/cli-module-build/src/commands/package/prepack.ts index e6aba2ce3f..4488f92c76 100644 --- a/packages/cli/src/modules/build/commands/package/prepack.ts +++ b/packages/cli-module-build/src/commands/package/prepack.ts @@ -20,9 +20,9 @@ import { targetPaths } from '@backstage/cli-common'; import { productionPack } from '../../lib/packager/productionPack'; import { publishPreflightCheck } from '../../lib/publishing'; import { createTypeDistProject } from '../../lib/typeDistProject'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); publishPreflightCheck({ diff --git a/packages/cli/src/modules/build/commands/package/start/command.ts b/packages/cli-module-build/src/commands/package/start/command.ts similarity index 95% rename from packages/cli/src/modules/build/commands/package/start/command.ts rename to packages/cli-module-build/src/commands/package/start/command.ts index fe871973de..8cee25732f 100644 --- a/packages/cli/src/modules/build/commands/package/start/command.ts +++ b/packages/cli-module-build/src/commands/package/start/command.ts @@ -19,9 +19,9 @@ import { startPackage } from './startPackage'; import { resolveLinkedWorkspace } from './resolveLinkedWorkspace'; import { findRoleFromCommand } from '../../../lib/role'; import { targetPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { config, diff --git a/packages/cli/src/modules/build/commands/package/start/index.ts b/packages/cli-module-build/src/commands/package/start/index.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/index.ts rename to packages/cli-module-build/src/commands/package/start/index.ts diff --git a/packages/cli/src/modules/build/commands/package/start/resolveLinkedWorkspace.ts b/packages/cli-module-build/src/commands/package/start/resolveLinkedWorkspace.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/resolveLinkedWorkspace.ts rename to packages/cli-module-build/src/commands/package/start/resolveLinkedWorkspace.ts diff --git a/packages/cli/src/modules/build/commands/package/start/startBackend.ts b/packages/cli-module-build/src/commands/package/start/startBackend.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/startBackend.ts rename to packages/cli-module-build/src/commands/package/start/startBackend.ts diff --git a/packages/cli/src/modules/build/commands/package/start/startFrontend.ts b/packages/cli-module-build/src/commands/package/start/startFrontend.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/startFrontend.ts rename to packages/cli-module-build/src/commands/package/start/startFrontend.ts diff --git a/packages/cli/src/modules/build/commands/package/start/startPackage.test.ts b/packages/cli-module-build/src/commands/package/start/startPackage.test.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/startPackage.test.ts rename to packages/cli-module-build/src/commands/package/start/startPackage.test.ts diff --git a/packages/cli/src/modules/build/commands/package/start/startPackage.ts b/packages/cli-module-build/src/commands/package/start/startPackage.ts similarity index 100% rename from packages/cli/src/modules/build/commands/package/start/startPackage.ts rename to packages/cli-module-build/src/commands/package/start/startPackage.ts diff --git a/packages/cli/src/modules/build/commands/repo/build.ts b/packages/cli-module-build/src/commands/repo/build.ts similarity index 97% rename from packages/cli/src/modules/build/commands/repo/build.ts rename to packages/cli-module-build/src/commands/repo/build.ts index 7140310464..d0bb9cab6c 100644 --- a/packages/cli/src/modules/build/commands/repo/build.ts +++ b/packages/cli-module-build/src/commands/repo/build.ts @@ -29,9 +29,9 @@ import { import { buildFrontend } from '../../lib/buildFrontend'; import { buildBackend } from '../../lib/buildBackend'; import { createScriptOptionsParser } from '../../lib/optionsParser'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { all, since, minify }, } = cli( diff --git a/packages/cli/src/modules/build/commands/repo/clean.ts b/packages/cli-module-build/src/commands/repo/clean.ts similarity index 93% rename from packages/cli/src/modules/build/commands/repo/clean.ts rename to packages/cli-module-build/src/commands/repo/clean.ts index 7814c1e4b7..12b1daf911 100644 --- a/packages/cli/src/modules/build/commands/repo/clean.ts +++ b/packages/cli-module-build/src/commands/repo/clean.ts @@ -19,9 +19,9 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; import { run, targetPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); const packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/build/commands/repo/start.test.ts b/packages/cli-module-build/src/commands/repo/start.test.ts similarity index 100% rename from packages/cli/src/modules/build/commands/repo/start.test.ts rename to packages/cli-module-build/src/commands/repo/start.test.ts diff --git a/packages/cli/src/modules/build/commands/repo/start.ts b/packages/cli-module-build/src/commands/repo/start.ts similarity index 98% rename from packages/cli/src/modules/build/commands/repo/start.ts rename to packages/cli-module-build/src/commands/repo/start.ts index e4c51a1e46..8cf3dceb1d 100644 --- a/packages/cli/src/modules/build/commands/repo/start.ts +++ b/packages/cli-module-build/src/commands/repo/start.ts @@ -26,7 +26,7 @@ import { cli } from 'cleye'; import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace'; import { startPackage } from '../package/start/startPackage'; import { parseArgs } from 'node:util'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const ACCEPTED_PACKAGE_ROLES: Array = [ 'frontend', @@ -35,7 +35,7 @@ const ACCEPTED_PACKAGE_ROLES: Array = [ 'backend-plugin', ]; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { plugin, config, require: requirePath, link, inspect, inspectBrk }, _: namesOrPaths, diff --git a/packages/cli/src/modules/build/index.ts b/packages/cli-module-build/src/index.ts similarity index 81% rename from packages/cli/src/modules/build/index.ts rename to packages/cli-module-build/src/index.ts index 139c591af2..cf892db53c 100644 --- a/packages/cli/src/modules/build/index.ts +++ b/packages/cli-module-build/src/index.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export const buildPlugin = createCliPlugin({ - pluginId: 'build', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['package', 'build'], @@ -32,6 +33,16 @@ export const buildPlugin = createCliPlugin({ execute: { loader: () => import('./commands/repo/build') }, }); + reg.addCommand({ + path: ['package', 'bundle'], + description: + 'Bundle a plugin for dynamic loading. Creates a self-contained plugin ' + + 'package that can be deployed and loaded dynamically by a Backstage application. ' + + 'Supports both backend and frontend plugins. Experimental.', + experimental: true, + execute: { loader: () => import('./commands/package/bundle') }, + }); + reg.addCommand({ path: ['package', 'start'], description: 'Start a package for local development', @@ -84,5 +95,3 @@ export const buildPlugin = createCliPlugin({ }); }, }); - -export default buildPlugin; diff --git a/packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts b/packages/cli-module-build/src/lib/__testUtils__/createFeatureEnvironment.ts similarity index 100% rename from packages/cli/src/modules/build/lib/__testUtils__/createFeatureEnvironment.ts rename to packages/cli-module-build/src/lib/__testUtils__/createFeatureEnvironment.ts diff --git a/packages/cli/src/modules/build/lib/buildBackend.ts b/packages/cli-module-build/src/lib/buildBackend.ts similarity index 100% rename from packages/cli/src/modules/build/lib/buildBackend.ts rename to packages/cli-module-build/src/lib/buildBackend.ts diff --git a/packages/cli/src/modules/build/lib/buildFrontend.ts b/packages/cli-module-build/src/lib/buildFrontend.ts similarity index 100% rename from packages/cli/src/modules/build/lib/buildFrontend.ts rename to packages/cli-module-build/src/lib/buildFrontend.ts diff --git a/packages/cli/src/modules/build/lib/builder/config.test.ts b/packages/cli-module-build/src/lib/builder/config.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/config.test.ts rename to packages/cli-module-build/src/lib/builder/config.test.ts diff --git a/packages/cli/src/modules/build/lib/builder/config.ts b/packages/cli-module-build/src/lib/builder/config.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/config.ts rename to packages/cli-module-build/src/lib/builder/config.ts diff --git a/packages/cli/src/modules/build/lib/builder/index.ts b/packages/cli-module-build/src/lib/builder/index.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/index.ts rename to packages/cli-module-build/src/lib/builder/index.ts diff --git a/packages/cli/src/modules/build/lib/builder/packager.test.ts b/packages/cli-module-build/src/lib/builder/packager.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/packager.test.ts rename to packages/cli-module-build/src/lib/builder/packager.test.ts diff --git a/packages/cli/src/modules/build/lib/builder/packager.ts b/packages/cli-module-build/src/lib/builder/packager.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/packager.ts rename to packages/cli-module-build/src/lib/builder/packager.ts diff --git a/packages/cli/src/modules/build/lib/builder/plugins.test.ts b/packages/cli-module-build/src/lib/builder/plugins.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/plugins.test.ts rename to packages/cli-module-build/src/lib/builder/plugins.test.ts diff --git a/packages/cli/src/modules/build/lib/builder/plugins.ts b/packages/cli-module-build/src/lib/builder/plugins.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/plugins.ts rename to packages/cli-module-build/src/lib/builder/plugins.ts diff --git a/packages/cli/src/modules/build/lib/builder/types.ts b/packages/cli-module-build/src/lib/builder/types.ts similarity index 100% rename from packages/cli/src/modules/build/lib/builder/types.ts rename to packages/cli-module-build/src/lib/builder/types.ts diff --git a/packages/cli/src/modules/build/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts b/packages/cli-module-build/src/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts rename to packages/cli-module-build/src/lib/bundler/ConfigInjectingHtmlWebpackPlugin.ts diff --git a/packages/cli/src/modules/build/lib/bundler/bundle.ts b/packages/cli-module-build/src/lib/bundler/bundle.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/bundle.ts rename to packages/cli-module-build/src/lib/bundler/bundle.ts diff --git a/packages/cli/src/modules/build/lib/bundler/config.ts b/packages/cli-module-build/src/lib/bundler/config.ts similarity index 97% rename from packages/cli/src/modules/build/lib/bundler/config.ts rename to packages/cli-module-build/src/lib/bundler/config.ts index c3676ecf70..cee7ba6819 100644 --- a/packages/cli/src/modules/build/lib/bundler/config.ts +++ b/packages/cli-module-build/src/lib/bundler/config.ts @@ -29,10 +29,10 @@ import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack'; import fs from 'fs-extra'; import { optimization as optimizationConfig } from './optimization'; import pickBy from 'lodash/pickBy'; -import { runOutput, targetPaths } from '@backstage/cli-common'; +import { findOwnPaths, runOutput, targetPaths } from '@backstage/cli-common'; import { transforms } from './transforms'; -import { version } from '../../../../wiring/version'; +import { version } from '../../../package.json'; import yn from 'yn'; import { hasReactDomClient } from './hasReactDomClient'; import { createWorkspaceLinkingPlugins } from './linkWorkspaces'; @@ -330,7 +330,8 @@ export async function createConfig( devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map', context: paths.targetPath, entry: [ - require.resolve('@backstage/cli/config/webpack-public-path'), + /* eslint-disable-next-line no-restricted-syntax */ + findOwnPaths(__dirname).resolve('config/webpack-public-path'), ...(options.additionalEntryPoints ?? []), paths.targetEntry, ], diff --git a/packages/cli/src/modules/build/lib/bundler/hasReactDomClient.ts b/packages/cli-module-build/src/lib/bundler/hasReactDomClient.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/hasReactDomClient.ts rename to packages/cli-module-build/src/lib/bundler/hasReactDomClient.ts diff --git a/packages/cli/src/modules/build/lib/bundler/index.ts b/packages/cli-module-build/src/lib/bundler/index.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/index.ts rename to packages/cli-module-build/src/lib/bundler/index.ts diff --git a/packages/cli/src/modules/build/lib/bundler/linkWorkspaces.ts b/packages/cli-module-build/src/lib/bundler/linkWorkspaces.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/linkWorkspaces.ts rename to packages/cli-module-build/src/lib/bundler/linkWorkspaces.ts diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.test.ts b/packages/cli-module-build/src/lib/bundler/moduleFederation.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/moduleFederation.test.ts rename to packages/cli-module-build/src/lib/bundler/moduleFederation.test.ts diff --git a/packages/cli/src/modules/build/lib/bundler/moduleFederation.ts b/packages/cli-module-build/src/lib/bundler/moduleFederation.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/moduleFederation.ts rename to packages/cli-module-build/src/lib/bundler/moduleFederation.ts diff --git a/packages/cli/src/modules/build/lib/bundler/optimization.ts b/packages/cli-module-build/src/lib/bundler/optimization.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/optimization.ts rename to packages/cli-module-build/src/lib/bundler/optimization.ts diff --git a/packages/cli/src/modules/build/lib/bundler/packageDetection.ts b/packages/cli-module-build/src/lib/bundler/packageDetection.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/packageDetection.ts rename to packages/cli-module-build/src/lib/bundler/packageDetection.ts diff --git a/packages/cli/src/modules/build/lib/bundler/paths.ts b/packages/cli-module-build/src/lib/bundler/paths.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/paths.ts rename to packages/cli-module-build/src/lib/bundler/paths.ts diff --git a/packages/cli/src/modules/build/lib/bundler/server.ts b/packages/cli-module-build/src/lib/bundler/server.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/server.ts rename to packages/cli-module-build/src/lib/bundler/server.ts diff --git a/packages/cli/src/modules/build/lib/bundler/transforms.ts b/packages/cli-module-build/src/lib/bundler/transforms.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/transforms.ts rename to packages/cli-module-build/src/lib/bundler/transforms.ts diff --git a/packages/cli/src/modules/build/lib/bundler/types.ts b/packages/cli-module-build/src/lib/bundler/types.ts similarity index 100% rename from packages/cli/src/modules/build/lib/bundler/types.ts rename to packages/cli-module-build/src/lib/bundler/types.ts diff --git a/packages/cli/src/modules/build/lib/config.ts b/packages/cli-module-build/src/lib/config.ts similarity index 100% rename from packages/cli/src/modules/build/lib/config.ts rename to packages/cli-module-build/src/lib/config.ts diff --git a/packages/cli/src/modules/build/lib/entryPoints.ts b/packages/cli-module-build/src/lib/entryPoints.ts similarity index 100% rename from packages/cli/src/modules/build/lib/entryPoints.ts rename to packages/cli-module-build/src/lib/entryPoints.ts diff --git a/packages/cli/src/modules/build/lib/ipc/IpcServer.ts b/packages/cli-module-build/src/lib/ipc/IpcServer.ts similarity index 100% rename from packages/cli/src/modules/build/lib/ipc/IpcServer.ts rename to packages/cli-module-build/src/lib/ipc/IpcServer.ts diff --git a/packages/cli/src/modules/build/lib/ipc/ServerDataStore.ts b/packages/cli-module-build/src/lib/ipc/ServerDataStore.ts similarity index 100% rename from packages/cli/src/modules/build/lib/ipc/ServerDataStore.ts rename to packages/cli-module-build/src/lib/ipc/ServerDataStore.ts diff --git a/packages/cli/src/modules/build/lib/ipc/index.ts b/packages/cli-module-build/src/lib/ipc/index.ts similarity index 100% rename from packages/cli/src/modules/build/lib/ipc/index.ts rename to packages/cli-module-build/src/lib/ipc/index.ts diff --git a/packages/cli/src/modules/build/lib/optionsParser.ts b/packages/cli-module-build/src/lib/optionsParser.ts similarity index 100% rename from packages/cli/src/modules/build/lib/optionsParser.ts rename to packages/cli-module-build/src/lib/optionsParser.ts diff --git a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts b/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts similarity index 77% rename from packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts rename to packages/cli-module-build/src/lib/packager/createDistWorkspace.ts index 9f63525186..72a06af69f 100644 --- a/packages/cli/src/modules/build/lib/packager/createDistWorkspace.ts +++ b/packages/cli-module-build/src/lib/packager/createDistWorkspace.ts @@ -26,11 +26,10 @@ import * as tar from 'tar'; import partition from 'lodash/partition'; import { run, targetPaths } from '@backstage/cli-common'; - import { dependencies as cliDependencies, devDependencies as cliDevDependencies, -} from '../../../../../package.json'; +} from '../../../package.json'; import { BuildOptions, buildPackages, @@ -39,6 +38,7 @@ import { } from '../builder'; import { productionPack } from './productionPack'; import { + BackstagePackage, PackageRoles, PackageGraph, PackageGraphNode, @@ -110,12 +110,21 @@ type Options = { * If set to true, the generated code will be minified. */ minify?: boolean; + + /** + * Optional logger to route console output through. When not provided, + * output goes to `console.log` / `console.warn` as before. + */ + logger?: { + log(msg: string): void; + warn(msg: string): void; + }; }; -function prefixLogFunc(prefix: string, out: 'stdout' | 'stderr') { +function prefixLogFunc(prefix: string, logFn: (msg: string) => void) { return (data: Buffer) => { for (const line of data.toString('utf8').split(/\r?\n/)) { - process[out].write(`${prefix} ${line}\n`); + if (line) logFn(`${prefix}${line}`); } }; } @@ -132,21 +141,14 @@ export async function createDistWorkspace( packageNames: string[], options: Options = {}, ) { + const logger = options.logger ?? { log: console.log, warn: console.warn }; + const targetDir = options.targetDir ?? (await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace'))); const packages = await PackageGraph.listTargetPackages(); - const packageGraph = PackageGraph.fromPackages(packages); - const targetNames = packageGraph.collectPackageNames(packageNames, node => { - // Don't include dependencies of packages that are marked as bundled - if (node.packageJson.bundled) { - return undefined; - } - - return node.publishedLocalDependencies.keys(); - }); - const targets = Array.from(targetNames).map(name => packageGraph.get(name)!); + const targets = await resolveLocalDependencies(packageNames, packages); if (options.buildDependencies) { const exclude = options.buildExcludes ?? []; @@ -169,7 +171,7 @@ export async function createDistWorkspace( } const role = pkg.packageJson.backstage?.role; if (!role) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it has no role`, ); customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name }); @@ -183,7 +185,7 @@ export async function createDistWorkspace( } if (!buildScript.startsWith('backstage-cli package build')) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it has a custom build script, '${buildScript}'`, ); customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name }); @@ -191,7 +193,7 @@ export async function createDistWorkspace( } if (PackageRoles.getRoleInfo(role).output.includes('bundle')) { - console.warn( + logger.warn( `Building ${pkg.packageJson.name} separately because it is a bundled package`, ); const args = buildScript.includes('--config') @@ -228,8 +230,8 @@ export async function createDistWorkspace( worker: async ({ name, dir, args }) => { await run(['yarn', 'run', 'build', ...(args || [])], { cwd: dir, - onStdout: prefixLogFunc(`${name}: `, 'stdout'), - onStderr: prefixLogFunc(`${name}: `, 'stderr'), + onStdout: prefixLogFunc(`${name}: `, logger.log), + onStderr: prefixLogFunc(`${name}: `, logger.warn), }).waitForExit(); }, }); @@ -241,6 +243,7 @@ export async function createDistWorkspace( targets, Boolean(options.alwaysPack), Boolean(options.enableFeatureDetection), + logger, ); const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; @@ -271,7 +274,7 @@ export async function createDistWorkspace( ); } - return targetDir; + return { targetDir, targets }; } const FAST_PACK_SCRIPTS = [ @@ -280,11 +283,40 @@ const FAST_PACK_SCRIPTS = [ 'backstage-cli package prepack', ]; +/** + * Runs `yarn pack` on a single package and extracts the resulting tarball + * into `targetDir`. This resolves `workspace:^` and `backstage:^` dependency + * specs to concrete versions via yarn's `beforeWorkspacePacking` hook. + */ +export async function packToDirectory(options: { + packageDir: string; + packageName: string; + targetDir: string; + archivePath?: string; + logger: { log(msg: string): void; warn(msg: string): void }; +}): Promise { + const { packageDir, packageName, targetDir, logger } = options; + const archivePath = + options.archivePath ?? resolvePath(targetDir, 'temp-archive.tgz'); + const prefix = `${packageName} [pack]: `; + + await fs.ensureDir(targetDir); + await run(['yarn', 'pack', '--filename', archivePath], { + cwd: packageDir, + onStdout: prefixLogFunc(prefix, logger.log), + onStderr: prefixLogFunc(prefix, logger.warn), + }).waitForExit(); + + await tar.extract({ file: archivePath, cwd: targetDir, strip: 1 }); + await fs.remove(archivePath); +} + async function moveToDistWorkspace( workspaceDir: string, localPackages: PackageGraphNode[], alwaysPack: boolean, enableFeatureDetection: boolean, + logger: { log(msg: string): void; warn(msg: string): void }, ): Promise { const [fastPackPackages, slowPackPackages] = partition( localPackages, @@ -301,7 +333,7 @@ async function moveToDistWorkspace( // New an improved flow where we avoid calling `yarn pack` await Promise.all( fastPackPackages.map(async target => { - console.log(`Moving ${target.name} into dist workspace`); + logger.log(`Moving ${target.name} into dist workspace`); const outputDir = relativePath(targetPaths.rootDir, target.dir); const absoluteOutputPath = resolvePath(workspaceDir, outputDir); @@ -316,23 +348,18 @@ async function moveToDistWorkspace( // Old flow is below, which calls `yarn pack` and extracts the tarball async function pack(target: PackageGraphNode, archive: string) { - console.log(`Repacking ${target.name} into dist workspace`); - const archivePath = resolvePath(workspaceDir, archive); - - await run(['yarn', 'pack', '--filename', archivePath], { - cwd: target.dir, - }).waitForExit(); - - const outputDir = relativePath(targetPaths.rootDir, target.dir); - const absoluteOutputPath = resolvePath(workspaceDir, outputDir); - await fs.ensureDir(absoluteOutputPath); - - await tar.extract({ - file: archivePath, - cwd: absoluteOutputPath, - strip: 1, + logger.log(`Repacking ${target.name} into dist workspace`); + const absoluteOutputPath = resolvePath( + workspaceDir, + relativePath(targetPaths.rootDir, target.dir), + ); + await packToDirectory({ + packageDir: target.dir, + packageName: target.name, + targetDir: absoluteOutputPath, + archivePath: resolvePath(workspaceDir, archive), + logger, }); - await fs.remove(archivePath); // We remove the dependencies from package.json of packages that are marked // as bundled, so that yarn doesn't try to install them. @@ -373,3 +400,28 @@ async function moveToDistWorkspace( }, }); } + +/** + * Resolves the full set of local (workspace) packages that the given + * package names transitively depend on via `publishedLocalDependencies`. + * Packages marked as `bundled` have their own dependencies excluded. + * + * This is the same traversal that `createDistWorkspace` performs internally. + * Callers must supply the monorepo package list obtained from + * `PackageGraph.listTargetPackages()`. + */ +export async function resolveLocalDependencies( + packageNames: string[], + packages: BackstagePackage[], +): Promise { + const packageGraph = PackageGraph.fromPackages(packages); + const targetNames = packageGraph.collectPackageNames(packageNames, node => { + // Don't include dependencies of packages that are marked as bundled + if (node.packageJson.bundled) { + return undefined; + } + + return node.publishedLocalDependencies.keys(); + }); + return Array.from(targetNames).map(name => packageGraph.get(name)!); +} diff --git a/packages/ui/src/components/InternalLinkProvider/index.ts b/packages/cli-module-build/src/lib/packager/index.ts similarity index 69% rename from packages/ui/src/components/InternalLinkProvider/index.ts rename to packages/cli-module-build/src/lib/packager/index.ts index d0facb962f..4b382d0340 100644 --- a/packages/ui/src/components/InternalLinkProvider/index.ts +++ b/packages/cli-module-build/src/lib/packager/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Backstage Authors + * Copyright 2022 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,7 @@ */ export { - InternalLinkProvider, - RoutedContainer, - useRoutingRegistration, - isInternalLink, - createRoutingRegistration, -} from './InternalLinkProvider'; -export type { RoutingContextValue } from './InternalLinkProvider'; + createDistWorkspace, + packToDirectory, + resolveLocalDependencies, +} from './createDistWorkspace'; diff --git a/packages/cli/src/modules/build/lib/packager/productionPack.ts b/packages/cli-module-build/src/lib/packager/productionPack.ts similarity index 100% rename from packages/cli/src/modules/build/lib/packager/productionPack.ts rename to packages/cli-module-build/src/lib/packager/productionPack.ts diff --git a/packages/cli/src/modules/build/lib/publishing.ts b/packages/cli-module-build/src/lib/publishing.ts similarity index 100% rename from packages/cli/src/modules/build/lib/publishing.ts rename to packages/cli-module-build/src/lib/publishing.ts diff --git a/packages/cli/src/modules/build/lib/role.test.ts b/packages/cli-module-build/src/lib/role.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/role.test.ts rename to packages/cli-module-build/src/lib/role.test.ts diff --git a/packages/cli/src/modules/build/lib/role.ts b/packages/cli-module-build/src/lib/role.ts similarity index 100% rename from packages/cli/src/modules/build/lib/role.ts rename to packages/cli-module-build/src/lib/role.ts diff --git a/packages/cli/src/modules/build/lib/runner/index.ts b/packages/cli-module-build/src/lib/runner/index.ts similarity index 100% rename from packages/cli/src/modules/build/lib/runner/index.ts rename to packages/cli-module-build/src/lib/runner/index.ts diff --git a/packages/cli/src/modules/build/lib/runner/runBackend.test.ts b/packages/cli-module-build/src/lib/runner/runBackend.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/runner/runBackend.test.ts rename to packages/cli-module-build/src/lib/runner/runBackend.test.ts diff --git a/packages/cli/src/modules/build/lib/runner/runBackend.ts b/packages/cli-module-build/src/lib/runner/runBackend.ts similarity index 98% rename from packages/cli/src/modules/build/lib/runner/runBackend.ts rename to packages/cli-module-build/src/lib/runner/runBackend.ts index 22fd50b3f4..86c249e8be 100644 --- a/packages/cli/src/modules/build/lib/runner/runBackend.ts +++ b/packages/cli-module-build/src/lib/runner/runBackend.ts @@ -28,8 +28,8 @@ import spawn from 'cross-spawn'; const loaderArgs = [ '--enable-source-maps', '--require', - require.resolve('@backstage/cli/config/nodeTransform.cjs'), - // TODO: Support modules, although there's currently no way to load them since import() is transpiled tp require() + require.resolve('@backstage/cli-node/config/nodeTransform.cjs'), + // TODO: Support modules, although there's currently no way to load them since import() is transpiled to require() ]; export type RunBackendOptions = { diff --git a/packages/cli/src/modules/build/lib/typeDistProject.test.ts b/packages/cli-module-build/src/lib/typeDistProject.test.ts similarity index 99% rename from packages/cli/src/modules/build/lib/typeDistProject.test.ts rename to packages/cli-module-build/src/lib/typeDistProject.test.ts index 6eef53816b..9d14bccc15 100644 --- a/packages/cli/src/modules/build/lib/typeDistProject.test.ts +++ b/packages/cli-module-build/src/lib/typeDistProject.test.ts @@ -33,6 +33,7 @@ describe('typeDistProject', () => { frontend: false, backend: false, cli: false, + 'cli-module': false, 'common-library': false, }; diff --git a/packages/cli/src/modules/build/lib/typeDistProject.ts b/packages/cli-module-build/src/lib/typeDistProject.ts similarity index 100% rename from packages/cli/src/modules/build/lib/typeDistProject.ts rename to packages/cli-module-build/src/lib/typeDistProject.ts diff --git a/packages/cli/src/modules/build/lib/urls.test.ts b/packages/cli-module-build/src/lib/urls.test.ts similarity index 100% rename from packages/cli/src/modules/build/lib/urls.test.ts rename to packages/cli-module-build/src/lib/urls.test.ts diff --git a/packages/cli/src/modules/build/lib/urls.ts b/packages/cli-module-build/src/lib/urls.ts similarity index 100% rename from packages/cli/src/modules/build/lib/urls.ts rename to packages/cli-module-build/src/lib/urls.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/.gitignore b/packages/cli-module-build/src/tests/transforms/__fixtures__/.gitignore similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/.gitignore rename to packages/cli-module-build/src/tests/transforms/__fixtures__/.gitignore diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-default.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/a-named.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-named.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-named.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.d.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/main.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-commonjs/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/a-default.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/a-named.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/b-default.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/b-named.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/c-default.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/c-named.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/main.d.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/main.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/main.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/main.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/main.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-default/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-default/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/a-default.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/a-named.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/b-default.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/b-named.mjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/c-default.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/c-named.cjs diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/main.d.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/main.js b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/main.js similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/main.js rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/main.js diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-module/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/node_modules/dep-module/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/a-default.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/a-named.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/b-default.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/b-named.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/c-default.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/c-named.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/main.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/main.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/main.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/main.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/print.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/print.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-commonjs/print.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-commonjs/print.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/a-default.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/a-default.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/a-default.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/a-default.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/a-named.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/a-named.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/a-named.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/a-named.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/b-default.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/b-default.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/b-default.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/b-default.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/b-named.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/b-named.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/b-named.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/b-named.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/c-default.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/c-default.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/c-default.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/c-default.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/c-named.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/c-named.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/c-named.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/c-named.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/main.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/main.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/main.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/main.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/print.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/print.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-default/print.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-default/print.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-default-explicit.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-default.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-default.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-default.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-default.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-named-explicit.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-named.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-named.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/a-named.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/a-named.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/b-default.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/b-default.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/b-default.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/b-default.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/b-named.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/b-named.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/b-named.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/b-named.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/c-default.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/c-default.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/c-default.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/c-default.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/c-named.cts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/c-named.cts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/c-named.cts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/c-named.cts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/main-explicit.mts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/main-explicit.mts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/main-explicit.mts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/main-explicit.mts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/main.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/main.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/main.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/main.ts diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/package.json b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/package.json similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/package.json rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/package.json diff --git a/packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/print.ts b/packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/print.ts similarity index 100% rename from packages/cli/src/modules/build/tests/transforms/__fixtures__/pkg-module/print.ts rename to packages/cli-module-build/src/tests/transforms/__fixtures__/pkg-module/print.ts diff --git a/packages/cli/src/modules/build/tests/transforms/transforms.test.ts b/packages/cli-module-build/src/tests/transforms/transforms.test.ts similarity index 99% rename from packages/cli/src/modules/build/tests/transforms/transforms.test.ts rename to packages/cli-module-build/src/tests/transforms/transforms.test.ts index 9e4fca8c63..71ee03df76 100644 --- a/packages/cli/src/modules/build/tests/transforms/transforms.test.ts +++ b/packages/cli-module-build/src/tests/transforms/transforms.test.ts @@ -55,7 +55,7 @@ function loadFixture(fixture: string) { 'node', [ '--import', - '@backstage/cli/config/nodeTransform.cjs', + require.resolve('@backstage/cli-node/config/nodeTransform.cjs'), resolvePath(__dirname, `__fixtures__/${fixture}`), ], { encoding: 'utf8' }, diff --git a/packages/cli-module-build/templates/serve_index.html b/packages/cli-module-build/templates/serve_index.html new file mode 100644 index 0000000000..bc91f1bc04 --- /dev/null +++ b/packages/cli-module-build/templates/serve_index.html @@ -0,0 +1,27 @@ + + + + + + + + Backstage + + + +
+ + + diff --git a/packages/cli-module-config/.eslintrc.js b/packages/cli-module-config/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-config/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-config/README.md b/packages/cli-module-config/README.md new file mode 100644 index 0000000000..dc5b266d98 --- /dev/null +++ b/packages/cli-module-config/README.md @@ -0,0 +1,18 @@ +# @backstage/cli-module-config + +CLI module that provides configuration inspection commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :-------------- | :------------------------------------------------------------- | +| `config docs` | Browse the configuration reference documentation | +| `config:print` | Print the app configuration for the current package | +| `config:check` | Validate that the given configuration loads and matches schema | +| `config schema` | Print the JSON schema for the given configuration | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) +- [Static Configuration](https://backstage.io/docs/conf/) diff --git a/packages/cli-module-config/bin/backstage-cli-module-config b/packages/cli-module-config/bin/backstage-cli-module-config new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-config/bin/backstage-cli-module-config @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-config/catalog-info.yaml b/packages/cli-module-config/catalog-info.yaml new file mode 100644 index 0000000000..b7c5d61aba --- /dev/null +++ b/packages/cli-module-config/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-config + title: '@backstage/cli-module-config' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-config/cli-report.md b/packages/cli-module-config/cli-report.md new file mode 100644 index 0000000000..953c347951 --- /dev/null +++ b/packages/cli-module-config/cli-report.md @@ -0,0 +1,109 @@ +## CLI Report file for "@backstage/cli-module-config" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-config` + +``` +Usage: @backstage/cli-module-config [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + config [command] + config:check + config:docs + config:print + config:schema + help [command] +``` + +### `backstage-cli-module-config config` + +``` +Usage: @backstage/cli-module-config config [options] [command] [command] + +Options: + -h, --help + +Commands: + docs + help [command] + schema +``` + +### `backstage-cli-module-config config docs` + +``` +Usage: @backstage/cli-module-config config docs + +Options: + --package + -h, --help +``` + +### `backstage-cli-module-config config schema` + +``` +Usage: @backstage/cli-module-config config schema + +Options: + --format + --merge + --package + -h, --help +``` + +### `backstage-cli-module-config config:check` + +``` +Usage: @backstage/cli-module-config config:check + +Options: + --config + --deprecated + --frontend + --lax + --package + --strict + -h, --help +``` + +### `backstage-cli-module-config config:docs` + +``` +Usage: @backstage/cli-module-config config:docs + +Options: + --package + -h, --help +``` + +### `backstage-cli-module-config config:print` + +``` +Usage: @backstage/cli-module-config config:print + +Options: + --config + --format + --frontend + --lax + --package + --with-secrets + -h, --help +``` + +### `backstage-cli-module-config config:schema` + +``` +Usage: @backstage/cli-module-config config:schema + +Options: + --format + --merge + --package + -h, --help +``` diff --git a/packages/cli-module-config/package.json b/packages/cli-module-config/package.json new file mode 100644 index 0000000000..bbdf6070a3 --- /dev/null +++ b/packages/cli-module-config/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/cli-module-config", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-config" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/config-loader": "workspace:^", + "@backstage/types": "workspace:^", + "@manypkg/get-packages": "^1.1.3", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "json-schema": "^0.4.0", + "react-dev-utils": "^12.0.0-next.60", + "yaml": "^2.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/json-schema": "^7.0.6" + }, + "bin": "bin/backstage-cli-module-config" +} diff --git a/packages/cli-module-config/report.api.md b/packages/cli-module-config/report.api.md new file mode 100644 index 0000000000..740aef21fb --- /dev/null +++ b/packages/cli-module-config/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-config" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/config/commands/docs.ts b/packages/cli-module-config/src/commands/docs.ts similarity index 94% rename from packages/cli/src/modules/config/commands/docs.ts rename to packages/cli-module-config/src/commands/docs.ts index 84d2f384e1..54cc1832f5 100644 --- a/packages/cli/src/modules/config/commands/docs.ts +++ b/packages/cli-module-config/src/commands/docs.ts @@ -21,11 +21,11 @@ import { JSONSchema7 as JSONSchema } from 'json-schema'; import openBrowser from 'react-dev-utils/openBrowser'; import chalk from 'chalk'; import { loadCliConfig } from '../lib/config'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const DOCS_URL = 'https://config.backstage.io'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { package: pkg }, } = cli( diff --git a/packages/cli/src/modules/config/commands/print.ts b/packages/cli-module-config/src/commands/print.ts similarity index 96% rename from packages/cli/src/modules/config/commands/print.ts rename to packages/cli-module-config/src/commands/print.ts index 33f7245604..08f785b1d0 100644 --- a/packages/cli/src/modules/config/commands/print.ts +++ b/packages/cli-module-config/src/commands/print.ts @@ -19,9 +19,9 @@ import { stringify as stringifyYaml } from 'yaml'; import { AppConfig, ConfigReader } from '@backstage/config'; import { loadCliConfig } from '../lib/config'; import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { config, lax, frontend, withSecrets, format, package: pkg }, } = cli( diff --git a/packages/cli/src/modules/config/commands/schema.ts b/packages/cli-module-config/src/commands/schema.ts similarity index 94% rename from packages/cli/src/modules/config/commands/schema.ts rename to packages/cli-module-config/src/commands/schema.ts index 13f651534c..4eaa8189ba 100644 --- a/packages/cli/src/modules/config/commands/schema.ts +++ b/packages/cli-module-config/src/commands/schema.ts @@ -20,9 +20,9 @@ import { stringify as stringifyYaml } from 'yaml'; import { loadCliConfig } from '../lib/config'; import { JsonObject } from '@backstage/types'; import { mergeConfigSchemas } from '@backstage/config-loader'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { merge, format, package: pkg }, } = cli( diff --git a/packages/cli/src/modules/config/commands/validate.ts b/packages/cli-module-config/src/commands/validate.ts similarity index 93% rename from packages/cli/src/modules/config/commands/validate.ts rename to packages/cli-module-config/src/commands/validate.ts index f6cf9cc662..326c6b5e42 100644 --- a/packages/cli/src/modules/config/commands/validate.ts +++ b/packages/cli-module-config/src/commands/validate.ts @@ -16,9 +16,9 @@ import { cli } from 'cleye'; import { loadCliConfig } from '../lib/config'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { config, lax, frontend, deprecated, strict, package: pkg }, } = cli( diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli-module-config/src/index.ts similarity index 85% rename from packages/cli/src/modules/config/index.ts rename to packages/cli-module-config/src/index.ts index 0d90037f18..a041f32d6e 100644 --- a/packages/cli/src/modules/config/index.ts +++ b/packages/cli-module-config/src/index.ts @@ -13,17 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export const configOption = [ - '--config ', - 'Config files to load instead of app-config.yaml', - (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), - Array(), -] as const; - -export default createCliPlugin({ - pluginId: 'config', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['config:docs'], diff --git a/packages/cli/src/modules/config/lib/config.ts b/packages/cli-module-config/src/lib/config.ts similarity index 100% rename from packages/cli/src/modules/config/lib/config.ts rename to packages/cli-module-config/src/lib/config.ts diff --git a/packages/cli-module-github/.eslintrc.js b/packages/cli-module-github/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-github/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-github/README.md b/packages/cli-module-github/README.md new file mode 100644 index 0000000000..6c38376901 --- /dev/null +++ b/packages/cli-module-github/README.md @@ -0,0 +1,14 @@ +# @backstage/cli-module-github + +CLI module that provides the `create-github-app` command for the Backstage CLI, used to create a new GitHub App in your organization for use with Backstage. + +## Commands + +| Command | Description | +| :------------------ | :----------------------------------------- | +| `create-github-app` | Create new GitHub App in your organization | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-github/bin/backstage-cli-module-github b/packages/cli-module-github/bin/backstage-cli-module-github new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-github/bin/backstage-cli-module-github @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-github/catalog-info.yaml b/packages/cli-module-github/catalog-info.yaml new file mode 100644 index 0000000000..b694da6f17 --- /dev/null +++ b/packages/cli-module-github/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-github + title: '@backstage/cli-module-github' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-github/cli-report.md b/packages/cli-module-github/cli-report.md new file mode 100644 index 0000000000..c606ac5988 --- /dev/null +++ b/packages/cli-module-github/cli-report.md @@ -0,0 +1,26 @@ +## CLI Report file for "@backstage/cli-module-github" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-github` + +``` +Usage: @backstage/cli-module-github [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + create-github-app + help [command] +``` + +### `backstage-cli-module-github create-github-app` + +``` +Usage: @backstage/cli-module-github create-github-app + +Options: + -h, --help +``` diff --git a/packages/cli-module-github/package.json b/packages/cli-module-github/package.json new file mode 100644 index 0000000000..1e8ca4fe6a --- /dev/null +++ b/packages/cli-module-github/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/cli-module-github", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-github" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@octokit/request": "^8.0.0", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "express": "^4.22.0", + "fs-extra": "^11.2.0", + "inquirer": "^8.2.0", + "react-dev-utils": "^12.0.0-next.60", + "yaml": "^2.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/express": "^4.17.6", + "@types/fs-extra": "^11.0.0" + }, + "bin": "bin/backstage-cli-module-github" +} diff --git a/packages/cli-module-github/report.api.md b/packages/cli-module-github/report.api.md new file mode 100644 index 0000000000..c389a88b5f --- /dev/null +++ b/packages/cli-module-github/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-github" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/create-github-app/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli-module-github/src/commands/create-github-app/GithubCreateAppServer.ts similarity index 100% rename from packages/cli/src/modules/create-github-app/commands/create-github-app/GithubCreateAppServer.ts rename to packages/cli-module-github/src/commands/create-github-app/GithubCreateAppServer.ts diff --git a/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts b/packages/cli-module-github/src/commands/create-github-app/index.ts similarity index 97% rename from packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts rename to packages/cli-module-github/src/commands/create-github-app/index.ts index 9dd9f4c4c4..bb6896c9c1 100644 --- a/packages/cli/src/modules/create-github-app/commands/create-github-app/index.ts +++ b/packages/cli-module-github/src/commands/create-github-app/index.ts @@ -23,12 +23,12 @@ import { cli } from 'cleye'; import { GithubCreateAppServer } from './GithubCreateAppServer'; import openBrowser from 'react-dev-utils/openBrowser'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; // This is an experimental command that at this point does not support GitHub Enterprise // due to lacking support for creating apps from manifests. // https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { _: positionals } = cli( { help: { ...info, usage: `${info.usage} ` }, diff --git a/packages/cli/src/modules/create-github-app/index.ts b/packages/cli-module-github/src/index.ts similarity index 85% rename from packages/cli/src/modules/create-github-app/index.ts rename to packages/cli-module-github/src/index.ts index 9dad46577e..c3bd7cf95f 100644 --- a/packages/cli/src/modules/create-github-app/index.ts +++ b/packages/cli-module-github/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'new', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['create-github-app'], diff --git a/packages/cli-module-info/.eslintrc.js b/packages/cli-module-info/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-info/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-info/README.md b/packages/cli-module-info/README.md new file mode 100644 index 0000000000..1ba0cb3799 --- /dev/null +++ b/packages/cli-module-info/README.md @@ -0,0 +1,14 @@ +# @backstage/cli-module-info + +CLI module that provides the `info` command for the Backstage CLI, displaying environment and dependency information useful for debugging and bug reports. + +## Commands + +| Command | Description | +| :------ | :-------------------------------------------------------- | +| `info` | Show helpful information for debugging and reporting bugs | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-info/bin/backstage-cli-module-info b/packages/cli-module-info/bin/backstage-cli-module-info new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-info/bin/backstage-cli-module-info @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-info/catalog-info.yaml b/packages/cli-module-info/catalog-info.yaml new file mode 100644 index 0000000000..2f6eb65952 --- /dev/null +++ b/packages/cli-module-info/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-info + title: '@backstage/cli-module-info' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-info/cli-report.md b/packages/cli-module-info/cli-report.md new file mode 100644 index 0000000000..f83583b781 --- /dev/null +++ b/packages/cli-module-info/cli-report.md @@ -0,0 +1,28 @@ +## CLI Report file for "@backstage/cli-module-info" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-info` + +``` +Usage: @backstage/cli-module-info [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + info +``` + +### `backstage-cli-module-info info` + +``` +Usage: @backstage/cli-module-info info + +Options: + --format + --include + -h, --help +``` diff --git a/packages/cli-module-info/package.json b/packages/cli-module-info/package.json new file mode 100644 index 0000000000..db19b358f4 --- /dev/null +++ b/packages/cli-module-info/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/cli-module-info", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-info" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "cleye": "^2.3.0", + "fs-extra": "^11.2.0", + "minimatch": "^10.2.1" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0" + }, + "bin": "bin/backstage-cli-module-info" +} diff --git a/packages/cli-module-info/report.api.md b/packages/cli-module-info/report.api.md new file mode 100644 index 0000000000..764b16ea47 --- /dev/null +++ b/packages/cli-module-info/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-info" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/info/commands/info.ts b/packages/cli-module-info/src/commands/info.ts similarity index 88% rename from packages/cli/src/modules/info/commands/info.ts rename to packages/cli-module-info/src/commands/info.ts index ab26714fca..e7307e18c2 100644 --- a/packages/cli/src/modules/info/commands/info.ts +++ b/packages/cli-module-info/src/commands/info.ts @@ -15,7 +15,7 @@ */ import { cli } from 'cleye'; -import { version as cliVersion } from '../../../../package.json'; +import { version as infoModuleVersion } from '../../package.json'; import os from 'node:os'; import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common'; import { @@ -25,7 +25,7 @@ import { } from '@backstage/cli-node'; import { minimatch } from 'minimatch'; import fs from 'fs-extra'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; /** * Attempts to read package.json from node_modules for a given package name. @@ -52,7 +52,7 @@ function hasBackstageField(packageName: string, targetPath: string): boolean { return pkg?.backstage !== undefined; } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { include, format }, } = cli( @@ -99,12 +99,27 @@ export default async ({ args, info }: CommandContext) => { } } - // Build system info + // Try to resolve the CLI package version — it may not be installed + // when this module is executed standalone. + let cliVersion: string | undefined; + try { + cliVersion = ( + require(require.resolve('@backstage/cli/package.json', { + paths: [process.cwd()], + })) as { version: string } + ).version; + } catch { + /* not available */ + } + const systemInfo = { os: `${os.type} ${os.release} - ${os.platform}/${os.arch}`, node: process.version, yarn: yarnVersion, - cli: { version: cliVersion, local: isLocal }, + ...(cliVersion + ? { cli: { version: cliVersion, local: isLocal } } + : undefined), + infoModuleVersion, backstage: backstageVersion, }; @@ -208,8 +223,11 @@ export default async ({ args, info }: CommandContext) => { console.log(`OS: ${systemInfo.os}`); console.log(`node: ${systemInfo.node}`); console.log(`yarn: ${systemInfo.yarn}`); - console.log(`cli: ${cliVersion} (${isLocal ? 'local' : 'installed'})`); - console.log(`backstage: ${backstageVersion}`); + if (cliVersion) { + console.log(`cli: ${cliVersion} (${isLocal ? 'local' : 'installed'})`); + } + console.log(`info module: ${infoModuleVersion}`); + console.log(`backstage: ${backstageVersion}`); console.log(); // Print installed dependencies diff --git a/packages/cli/src/modules/info/index.ts b/packages/cli-module-info/src/index.ts similarity index 85% rename from packages/cli/src/modules/info/index.ts rename to packages/cli-module-info/src/index.ts index 8e1fe9cbff..c7ace24bbd 100644 --- a/packages/cli/src/modules/info/index.ts +++ b/packages/cli-module-info/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'info', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['info'], diff --git a/packages/cli-module-lint/.eslintrc.js b/packages/cli-module-lint/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-lint/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-lint/README.md b/packages/cli-module-lint/README.md new file mode 100644 index 0000000000..23d1f06e7e --- /dev/null +++ b/packages/cli-module-lint/README.md @@ -0,0 +1,15 @@ +# @backstage/cli-module-lint + +CLI module that provides linting commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :------------- | :---------------- | +| `package lint` | Lint a package | +| `repo lint` | Lint a repository | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-lint/bin/backstage-cli-module-lint b/packages/cli-module-lint/bin/backstage-cli-module-lint new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-lint/bin/backstage-cli-module-lint @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-lint/catalog-info.yaml b/packages/cli-module-lint/catalog-info.yaml new file mode 100644 index 0000000000..d40b7e23e3 --- /dev/null +++ b/packages/cli-module-lint/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-lint + title: '@backstage/cli-module-lint' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-lint/cli-report.md b/packages/cli-module-lint/cli-report.md new file mode 100644 index 0000000000..2a9ff6d61f --- /dev/null +++ b/packages/cli-module-lint/cli-report.md @@ -0,0 +1,73 @@ +## CLI Report file for "@backstage/cli-module-lint" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-lint` + +``` +Usage: @backstage/cli-module-lint [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + package [command] + repo [command] +``` + +### `backstage-cli-module-lint package` + +``` +Usage: @backstage/cli-module-lint package [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + lint +``` + +### `backstage-cli-module-lint package lint` + +``` +Usage: @backstage/cli-module-lint package lint [directories...] + +Options: + --fix + --format + --max-warnings + --output-file + -h, --help +``` + +### `backstage-cli-module-lint repo` + +``` +Usage: @backstage/cli-module-lint repo [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + lint +``` + +### `backstage-cli-module-lint repo lint` + +``` +Usage: @backstage/cli-module-lint repo lint + +Options: + --fix + --format + --max-warnings + --output-file + --since + --success-cache + --success-cache-dir + -h, --help +``` diff --git a/packages/cli-module-lint/package.json b/packages/cli-module-lint/package.json new file mode 100644 index 0000000000..452e10eeed --- /dev/null +++ b/packages/cli-module-lint/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/cli-module-lint", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-lint" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "eslint": "^8.6.0", + "eslint-formatter-friendly": "^7.0.0", + "fs-extra": "^11.2.0", + "globby": "^11.1.0", + "shell-quote": "^1.8.1" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0", + "@types/shell-quote": "^1.7.5" + }, + "bin": "bin/backstage-cli-module-lint" +} diff --git a/packages/cli-module-lint/report.api.md b/packages/cli-module-lint/report.api.md new file mode 100644 index 0000000000..4a861a27a0 --- /dev/null +++ b/packages/cli-module-lint/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-lint" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/lint/commands/package/lint.ts b/packages/cli-module-lint/src/commands/package/lint.ts similarity index 95% rename from packages/cli/src/modules/lint/commands/package/lint.ts rename to packages/cli-module-lint/src/commands/package/lint.ts index 39c74bab73..7fabf0c0ff 100644 --- a/packages/cli/src/modules/lint/commands/package/lint.ts +++ b/packages/cli-module-lint/src/commands/package/lint.ts @@ -18,9 +18,9 @@ import fs from 'fs-extra'; import { cli } from 'cleye'; import { targetPaths } from '@backstage/cli-common'; import { ESLint } from 'eslint'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { fix, format, outputFile, maxWarnings }, _: directories, diff --git a/packages/cli/src/modules/lint/commands/repo/lint.ts b/packages/cli-module-lint/src/commands/repo/lint.ts similarity index 98% rename from packages/cli/src/modules/lint/commands/repo/lint.ts rename to packages/cli-module-lint/src/commands/repo/lint.ts index 5ce4be72f8..01dfad6339 100644 --- a/packages/cli/src/modules/lint/commands/repo/lint.ts +++ b/packages/cli-module-lint/src/commands/repo/lint.ts @@ -29,7 +29,7 @@ import { import { targetPaths } from '@backstage/cli-common'; import { createScriptOptionsParser } from '../../lib/optionsParser'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; @@ -39,7 +39,7 @@ function depCount(pkg: BackstagePackageJson) { return deps + devDeps; } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { for (const flag of [ 'outputFile', 'successCache', diff --git a/packages/cli/src/modules/lint/index.ts b/packages/cli-module-lint/src/index.ts similarity index 86% rename from packages/cli/src/modules/lint/index.ts rename to packages/cli-module-lint/src/index.ts index 8e02b7653f..d202841e79 100644 --- a/packages/cli/src/modules/lint/index.ts +++ b/packages/cli-module-lint/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'lint', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['package', 'lint'], diff --git a/packages/cli/src/modules/lint/lib/optionsParser.ts b/packages/cli-module-lint/src/lib/optionsParser.ts similarity index 100% rename from packages/cli/src/modules/lint/lib/optionsParser.ts rename to packages/cli-module-lint/src/lib/optionsParser.ts diff --git a/packages/cli-module-maintenance/.eslintrc.js b/packages/cli-module-maintenance/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-maintenance/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-maintenance/README.md b/packages/cli-module-maintenance/README.md new file mode 100644 index 0000000000..f13e78a32e --- /dev/null +++ b/packages/cli-module-maintenance/README.md @@ -0,0 +1,15 @@ +# @backstage/cli-module-maintenance + +CLI module that provides repository maintenance commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :----------------------- | :---------------------------------------- | +| `repo fix` | Automatically fix packages in the project | +| `repo list-deprecations` | List deprecations | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-maintenance/bin/backstage-cli-module-maintenance b/packages/cli-module-maintenance/bin/backstage-cli-module-maintenance new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-maintenance/bin/backstage-cli-module-maintenance @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-maintenance/catalog-info.yaml b/packages/cli-module-maintenance/catalog-info.yaml new file mode 100644 index 0000000000..df05034045 --- /dev/null +++ b/packages/cli-module-maintenance/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-maintenance + title: '@backstage/cli-module-maintenance' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-maintenance/cli-report.md b/packages/cli-module-maintenance/cli-report.md new file mode 100644 index 0000000000..874d9d781d --- /dev/null +++ b/packages/cli-module-maintenance/cli-report.md @@ -0,0 +1,52 @@ +## CLI Report file for "@backstage/cli-module-maintenance" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-maintenance` + +``` +Usage: @backstage/cli-module-maintenance [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + repo [command] +``` + +### `backstage-cli-module-maintenance repo` + +``` +Usage: @backstage/cli-module-maintenance repo [options] [command] [command] + +Options: + -h, --help + +Commands: + fix + help [command] + list-deprecations +``` + +### `backstage-cli-module-maintenance repo fix` + +``` +Usage: @backstage/cli-module-maintenance repo fix + +Options: + --check + --publish + -h, --help +``` + +### `backstage-cli-module-maintenance repo list-deprecations` + +``` +Usage: @backstage/cli-module-maintenance repo list-deprecations + +Options: + --json + -h, --help +``` diff --git a/packages/cli-module-maintenance/package.json b/packages/cli-module-maintenance/package.json new file mode 100644 index 0000000000..24f7a9b70a --- /dev/null +++ b/packages/cli-module-maintenance/package.json @@ -0,0 +1,47 @@ +{ + "name": "@backstage/cli-module-maintenance", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-maintenance" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "eslint": "^8.6.0", + "fs-extra": "^11.2.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0" + }, + "bin": "bin/backstage-cli-module-maintenance" +} diff --git a/packages/cli-module-maintenance/report.api.md b/packages/cli-module-maintenance/report.api.md new file mode 100644 index 0000000000..9eb59483d7 --- /dev/null +++ b/packages/cli-module-maintenance/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-maintenance" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/maintenance/commands/repo/fix.ts b/packages/cli-module-maintenance/src/commands/repo/fix.ts similarity index 98% rename from packages/cli/src/modules/maintenance/commands/repo/fix.ts rename to packages/cli-module-maintenance/src/commands/repo/fix.ts index dc2e554121..4d6445fe86 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/fix.ts +++ b/packages/cli-module-maintenance/src/commands/repo/fix.ts @@ -294,7 +294,12 @@ export function fixPluginId(pkg: FixablePackage) { return; } - if (role === 'backend' || role === 'frontend' || role === 'cli') { + if ( + role === 'backend' || + role === 'frontend' || + role === 'cli' || + role === 'cli-module' + ) { return; } @@ -376,7 +381,12 @@ export function fixPluginPackages( return; } - if (role === 'backend' || role === 'frontend' || role === 'cli') { + if ( + role === 'backend' || + role === 'frontend' || + role === 'cli' || + role === 'cli-module' + ) { return; } @@ -495,7 +505,7 @@ type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void; export default async ({ args, info, -}: import('../../../../wiring/types').CommandContext) => { +}: import('@backstage/cli-node').CliCommandContext) => { const { flags: { publish, check }, } = cli( diff --git a/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts b/packages/cli-module-maintenance/src/commands/repo/list-deprecations.ts similarity index 95% rename from packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts rename to packages/cli-module-maintenance/src/commands/repo/list-deprecations.ts index 23f64463cb..e711cb32be 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/list-deprecations.ts +++ b/packages/cli-module-maintenance/src/commands/repo/list-deprecations.ts @@ -20,9 +20,9 @@ import { cli } from 'cleye'; import { relative as relativePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { json }, } = cli( diff --git a/packages/cli/src/modules/maintenance/index.ts b/packages/cli-module-maintenance/src/index.ts similarity index 87% rename from packages/cli/src/modules/maintenance/index.ts rename to packages/cli-module-maintenance/src/index.ts index a6597559f8..77304eef47 100644 --- a/packages/cli/src/modules/maintenance/index.ts +++ b/packages/cli-module-maintenance/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'maintenance', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['repo', 'fix'], diff --git a/packages/cli-module-migrate/.eslintrc.js b/packages/cli-module-migrate/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-migrate/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-migrate/README.md b/packages/cli-module-migrate/README.md new file mode 100644 index 0000000000..842f0e645c --- /dev/null +++ b/packages/cli-module-migrate/README.md @@ -0,0 +1,20 @@ +# @backstage/cli-module-migrate + +CLI module that provides migration and version management commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :----------------------------- | :---------------------------------------------------------------- | +| `versions:bump` | Bump Backstage packages to the latest versions | +| `versions:migrate` | Migrate plugins moved to the @backstage-community namespace | +| `migrate package-roles` | Add package role field to packages that don't have it | +| `migrate package-scripts` | Set package scripts according to each package role | +| `migrate package-exports` | Synchronize package subpath export definitions | +| `migrate package-lint-configs` | Migrates all packages to use @backstage/cli/config/eslint-factory | +| `migrate react-router-deps` | Migrates the react-router dependencies to be peer dependencies | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-migrate/bin/backstage-cli-module-migrate b/packages/cli-module-migrate/bin/backstage-cli-module-migrate new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-migrate/bin/backstage-cli-module-migrate @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-migrate/catalog-info.yaml b/packages/cli-module-migrate/catalog-info.yaml new file mode 100644 index 0000000000..5c62edf9ba --- /dev/null +++ b/packages/cli-module-migrate/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-migrate + title: '@backstage/cli-module-migrate' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-migrate/cli-report.md b/packages/cli-module-migrate/cli-report.md new file mode 100644 index 0000000000..f0c29e1edd --- /dev/null +++ b/packages/cli-module-migrate/cli-report.md @@ -0,0 +1,105 @@ +## CLI Report file for "@backstage/cli-module-migrate" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-migrate` + +``` +Usage: @backstage/cli-module-migrate [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + migrate [command] + versions:bump + versions:migrate +``` + +### `backstage-cli-module-migrate migrate` + +``` +Usage: @backstage/cli-module-migrate migrate [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + package-exports + package-lint-configs + package-roles + package-scripts + react-router-deps +``` + +### `backstage-cli-module-migrate migrate package-exports` + +``` +Usage: @backstage/cli-module-migrate migrate package-exports + +Options: + -h, --help +``` + +### `backstage-cli-module-migrate migrate package-lint-configs` + +``` +Usage: @backstage/cli-module-migrate migrate package-lint-configs + +Options: + -h, --help +``` + +### `backstage-cli-module-migrate migrate package-roles` + +``` +Usage: @backstage/cli-module-migrate migrate package-roles + +Options: + -h, --help +``` + +### `backstage-cli-module-migrate migrate package-scripts` + +``` +Usage: @backstage/cli-module-migrate migrate package-scripts + +Options: + -h, --help +``` + +### `backstage-cli-module-migrate migrate react-router-deps` + +``` +Usage: @backstage/cli-module-migrate migrate react-router-deps + +Options: + -h, --help +``` + +### `backstage-cli-module-migrate versions:bump` + +``` +Usage: @backstage/cli-module-migrate versions:bump + +Options: + --pattern + --release + --skip-install + --skip-migrate + -h, --help +``` + +### `backstage-cli-module-migrate versions:migrate` + +``` +Usage: @backstage/cli-module-migrate versions:migrate + +Options: + --pattern + --skip-code-changes + -h, --help +``` diff --git a/packages/cli-module-migrate/package.json b/packages/cli-module-migrate/package.json new file mode 100644 index 0000000000..dfc155fd11 --- /dev/null +++ b/packages/cli-module-migrate/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/cli-module-migrate", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-migrate" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/release-manifests": "workspace:^", + "@manypkg/get-packages": "^1.1.3", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "fs-extra": "^11.2.0", + "minimatch": "^10.2.1", + "ora": "^5.3.0", + "replace-in-file": "^7.1.0", + "semver": "^7.5.3" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/fs-extra": "^11.0.0", + "@types/semver": "^7", + "msw": "^1.0.0" + }, + "bin": "bin/backstage-cli-module-migrate" +} diff --git a/packages/cli-module-migrate/report.api.md b/packages/cli-module-migrate/report.api.md new file mode 100644 index 0000000000..09ed6c902a --- /dev/null +++ b/packages/cli-module-migrate/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-migrate" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/migrate/commands/packageExports.ts b/packages/cli-module-migrate/src/commands/packageExports.ts similarity index 86% rename from packages/cli/src/modules/migrate/commands/packageExports.ts rename to packages/cli-module-migrate/src/commands/packageExports.ts index 7fe6a49b35..8a4ad45f42 100644 --- a/packages/cli/src/modules/migrate/commands/packageExports.ts +++ b/packages/cli-module-migrate/src/commands/packageExports.ts @@ -15,9 +15,9 @@ */ import { cli } from 'cleye'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); throw new Error( 'The `migrate package-exports` command has been removed, use `repo fix` instead.', diff --git a/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts b/packages/cli-module-migrate/src/commands/packageLintConfigs.ts similarity index 95% rename from packages/cli/src/modules/migrate/commands/packageLintConfigs.ts rename to packages/cli-module-migrate/src/commands/packageLintConfigs.ts index 73cbe380ec..660ed35bb6 100644 --- a/packages/cli/src/modules/migrate/commands/packageLintConfigs.ts +++ b/packages/cli-module-migrate/src/commands/packageLintConfigs.ts @@ -19,11 +19,11 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph } from '@backstage/cli-node'; import { runOutput } from '@backstage/cli-common'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); const packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/migrate/commands/packageRole.ts b/packages/cli-module-migrate/src/commands/packageRole.ts similarity index 94% rename from packages/cli/src/modules/migrate/commands/packageRole.ts rename to packages/cli-module-migrate/src/commands/packageRole.ts index cc909a351c..8758d48dd5 100644 --- a/packages/cli/src/modules/migrate/commands/packageRole.ts +++ b/packages/cli-module-migrate/src/commands/packageRole.ts @@ -20,9 +20,9 @@ import { resolve as resolvePath } from 'node:path'; import { getPackages } from '@manypkg/get-packages'; import { PackageRoles } from '@backstage/cli-node'; import { targetPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); const { packages } = await getPackages(targetPaths.dir); diff --git a/packages/cli/src/modules/migrate/commands/packageScripts.ts b/packages/cli-module-migrate/src/commands/packageScripts.ts similarity index 94% rename from packages/cli/src/modules/migrate/commands/packageScripts.ts rename to packages/cli-module-migrate/src/commands/packageScripts.ts index 901cd8fc82..94a05f67f9 100644 --- a/packages/cli/src/modules/migrate/commands/packageScripts.ts +++ b/packages/cli-module-migrate/src/commands/packageScripts.ts @@ -18,13 +18,13 @@ import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph, PackageRoles, PackageRole } from '@backstage/cli-node'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const configArgPattern = /--config[=\s][^\s$]+/; -const noStartRoles: PackageRole[] = ['cli', 'common-library']; +const noStartRoles: PackageRole[] = ['cli', 'cli-module', 'common-library']; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); const packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts b/packages/cli-module-migrate/src/commands/reactRouterDeps.ts similarity index 94% rename from packages/cli/src/modules/migrate/commands/reactRouterDeps.ts rename to packages/cli-module-migrate/src/commands/reactRouterDeps.ts index b2208e2dc4..802d958e61 100644 --- a/packages/cli/src/modules/migrate/commands/reactRouterDeps.ts +++ b/packages/cli-module-migrate/src/commands/reactRouterDeps.ts @@ -18,12 +18,12 @@ import { cli } from 'cleye'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'node:path'; import { PackageGraph, PackageRoles } from '@backstage/cli-node'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom']; const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { cli({ help: info, booleanFlagNegation: true }, undefined, args); const packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli-module-migrate/src/commands/versions/bump.test.ts similarity index 99% rename from packages/cli/src/modules/migrate/commands/versions/bump.test.ts rename to packages/cli-module-migrate/src/commands/versions/bump.test.ts index f119a0bedb..c7694f5e09 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli-module-migrate/src/commands/versions/bump.test.ts @@ -125,7 +125,7 @@ const expectLogsToMatch = ( expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort()); }; -const info = { usage: 'backstage-cli versions:bump', description: '' }; +const info = { usage: 'backstage-cli versions:bump', name: 'versions:bump' }; describe('bump', () => { const mockDir = createMockDirectory(); diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli-module-migrate/src/commands/versions/bump.ts similarity index 99% rename from packages/cli/src/modules/migrate/commands/versions/bump.ts rename to packages/cli-module-migrate/src/commands/versions/bump.ts index 8048c21505..518126a3de 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli-module-migrate/src/commands/versions/bump.ts @@ -48,7 +48,7 @@ import { import { migrateMovedPackages } from './migrate'; import { runYarnInstall } from '../../lib/utils'; import { run } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; const DEP_TYPES = [ 'dependencies', @@ -74,7 +74,7 @@ function extendsDefaultPattern(pattern: string): boolean { return minimatch('@backstage/', pattern.slice(0, -1)); } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { pattern: patternFlag, release, skipInstall, skipMigrate }, } = cli( diff --git a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts b/packages/cli-module-migrate/src/commands/versions/migrate.test.ts similarity index 97% rename from packages/cli/src/modules/migrate/commands/versions/migrate.test.ts rename to packages/cli-module-migrate/src/commands/versions/migrate.test.ts index fc50a5c491..606fa528b1 100644 --- a/packages/cli/src/modules/migrate/commands/versions/migrate.test.ts +++ b/packages/cli-module-migrate/src/commands/versions/migrate.test.ts @@ -123,7 +123,7 @@ describe('versions:migrate', () => { }); const { warn, log: logs } = await withLogCollector(async () => { - await migrate({ args: [], info: { usage: 'test', description: 'test' } }); + await migrate({ args: [], info: { usage: 'test', name: 'test' } }); }); expectLogsToMatch(logs, [ @@ -229,7 +229,7 @@ describe('versions:migrate', () => { }); await withLogCollector(async () => { - await migrate({ args: [], info: { usage: 'test', description: 'test' } }); + await migrate({ args: [], info: { usage: 'test', name: 'test' } }); }); expect(runObj.run).toHaveBeenCalledTimes(1); @@ -311,7 +311,7 @@ describe('versions:migrate', () => { }); await withLogCollector(async () => { - await migrate({ args: [], info: { usage: 'test', description: 'test' } }); + await migrate({ args: [], info: { usage: 'test', name: 'test' } }); }); expect(runObj.run).toHaveBeenCalledTimes(1); diff --git a/packages/cli/src/modules/migrate/commands/versions/migrate.ts b/packages/cli-module-migrate/src/commands/versions/migrate.ts similarity index 97% rename from packages/cli/src/modules/migrate/commands/versions/migrate.ts rename to packages/cli-module-migrate/src/commands/versions/migrate.ts index 1286af015e..a8ecb6cad4 100644 --- a/packages/cli/src/modules/migrate/commands/versions/migrate.ts +++ b/packages/cli-module-migrate/src/commands/versions/migrate.ts @@ -21,7 +21,7 @@ import { readJson, writeJson } from 'fs-extra'; import { minimatch } from 'minimatch'; import { runYarnInstall } from '../../lib/utils'; import replace from 'replace-in-file'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; declare module 'replace-in-file' { export default function (config: { @@ -39,7 +39,7 @@ declare module 'replace-in-file' { >; } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { pattern, skipCodeChanges }, } = cli( diff --git a/packages/cli/src/modules/migrate/index.ts b/packages/cli-module-migrate/src/index.ts similarity index 94% rename from packages/cli/src/modules/migrate/index.ts rename to packages/cli-module-migrate/src/index.ts index c731479a51..e5d89383b8 100644 --- a/packages/cli/src/modules/migrate/index.ts +++ b/packages/cli-module-migrate/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'migrate', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['versions:migrate'], diff --git a/packages/cli/src/modules/migrate/lib/utils.ts b/packages/cli-module-migrate/src/lib/utils.ts similarity index 100% rename from packages/cli/src/modules/migrate/lib/utils.ts rename to packages/cli-module-migrate/src/lib/utils.ts diff --git a/packages/cli/src/modules/migrate/lib/versioning/packages.test.ts b/packages/cli-module-migrate/src/lib/versioning/packages.test.ts similarity index 100% rename from packages/cli/src/modules/migrate/lib/versioning/packages.test.ts rename to packages/cli-module-migrate/src/lib/versioning/packages.test.ts diff --git a/packages/cli/src/modules/migrate/lib/versioning/packages.ts b/packages/cli-module-migrate/src/lib/versioning/packages.ts similarity index 100% rename from packages/cli/src/modules/migrate/lib/versioning/packages.ts rename to packages/cli-module-migrate/src/lib/versioning/packages.ts diff --git a/packages/cli/src/modules/migrate/lib/versioning/yarn.ts b/packages/cli-module-migrate/src/lib/versioning/yarn.ts similarity index 100% rename from packages/cli/src/modules/migrate/lib/versioning/yarn.ts rename to packages/cli-module-migrate/src/lib/versioning/yarn.ts diff --git a/packages/cli-module-new/.eslintrc.js b/packages/cli-module-new/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-new/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-new/README.md b/packages/cli-module-new/README.md new file mode 100644 index 0000000000..88855e856a --- /dev/null +++ b/packages/cli-module-new/README.md @@ -0,0 +1,14 @@ +# @backstage/cli-module-new + +CLI module that provides the `new` command for the Backstage CLI, offering an interactive guide to scaffold new plugins, packages, and modules in your Backstage app. + +## Commands + +| Command | Description | +| :------ | :-------------------------------------------------------------- | +| `new` | Open up an interactive guide to creating new things in your app | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-new/bin/backstage-cli-module-new b/packages/cli-module-new/bin/backstage-cli-module-new new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-new/bin/backstage-cli-module-new @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-new/catalog-info.yaml b/packages/cli-module-new/catalog-info.yaml new file mode 100644 index 0000000000..997f06d629 --- /dev/null +++ b/packages/cli-module-new/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-new + title: '@backstage/cli-module-new' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-new/cli-report.md b/packages/cli-module-new/cli-report.md new file mode 100644 index 0000000000..a2b4768148 --- /dev/null +++ b/packages/cli-module-new/cli-report.md @@ -0,0 +1,34 @@ +## CLI Report file for "@backstage/cli-module-new" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-new` + +``` +Usage: @backstage/cli-module-new [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + new +``` + +### `backstage-cli-module-new new` + +``` +Usage: @backstage/cli-module-new new + +Options: + --base-version + --license + --npm-registry + --option + --private + --scope + --select + --skip-install + -h, --help +``` diff --git a/packages/cli-module-new/package.json b/packages/cli-module-new/package.json new file mode 100644 index 0000000000..9ca1664f7a --- /dev/null +++ b/packages/cli-module-new/package.json @@ -0,0 +1,62 @@ +{ + "name": "@backstage/cli-module-new", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-new" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@backstage/errors": "workspace:^", + "chalk": "^4.0.0", + "cleye": "^2.3.0", + "fs-extra": "^11.2.0", + "handlebars": "^4.7.3", + "inquirer": "^8.2.0", + "lodash": "^4.17.21", + "ora": "^5.3.0", + "recursive-readdir": "^2.2.2", + "semver": "^7.5.3", + "yaml": "^2.0.0", + "zod": "^3.25.76", + "zod-validation-error": "^4.0.2" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/fs-extra": "^11.0.0", + "@types/inquirer": "^8.1.3", + "@types/lodash": "^4.14.151", + "@types/recursive-readdir": "^2.2.0" + }, + "bin": "bin/backstage-cli-module-new" +} diff --git a/packages/cli-module-new/report.api.md b/packages/cli-module-new/report.api.md new file mode 100644 index 0000000000..0a9924e731 --- /dev/null +++ b/packages/cli-module-new/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-new" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/new/commands/new.test.ts b/packages/cli-module-new/src/commands/new.test.ts similarity index 91% rename from packages/cli/src/modules/new/commands/new.test.ts rename to packages/cli-module-new/src/commands/new.test.ts index 0da42e7019..54663ab60d 100644 --- a/packages/cli/src/modules/new/commands/new.test.ts +++ b/packages/cli-module-new/src/commands/new.test.ts @@ -16,7 +16,7 @@ import { createNewPackage } from '../lib/createNewPackage'; import { default as newCommand } from './new'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; jest.mock('../lib/createNewPackage'); @@ -39,9 +39,9 @@ describe.each([ if (scope) { args.push('--scope', scope); } - const context: CommandContext = { + const context: CliCommandContext = { args, - info: { usage: 'backstage-cli new', description: 'test' }, + info: { usage: 'backstage-cli new', name: 'new' }, }; await newCommand(context); expect(createNewPackage).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/new/commands/new.ts b/packages/cli-module-new/src/commands/new.ts similarity index 96% rename from packages/cli/src/modules/new/commands/new.ts rename to packages/cli-module-new/src/commands/new.ts index 3769e19269..3d7fcdf4e3 100644 --- a/packages/cli/src/modules/new/commands/new.ts +++ b/packages/cli-module-new/src/commands/new.ts @@ -16,9 +16,9 @@ import { cli } from 'cleye'; import { createNewPackage } from '../lib/createNewPackage'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { for (const flag of ['skipInstall', 'npmRegistry', 'baseVersion']) { if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) { process.stderr.write( diff --git a/packages/cli/src/modules/new/index.ts b/packages/cli-module-new/src/index.ts similarity index 91% rename from packages/cli/src/modules/new/index.ts rename to packages/cli-module-new/src/index.ts index 5b2a6562ea..c9bc322083 100644 --- a/packages/cli/src/modules/new/index.ts +++ b/packages/cli-module-new/src/index.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; import { NotImplementedError } from '@backstage/errors'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'new', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['new'], diff --git a/packages/cli/src/modules/new/lib/codeowners/codeowners.test.ts b/packages/cli-module-new/src/lib/codeowners/codeowners.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/codeowners/codeowners.test.ts rename to packages/cli-module-new/src/lib/codeowners/codeowners.test.ts diff --git a/packages/cli/src/modules/new/lib/codeowners/codeowners.ts b/packages/cli-module-new/src/lib/codeowners/codeowners.ts similarity index 100% rename from packages/cli/src/modules/new/lib/codeowners/codeowners.ts rename to packages/cli-module-new/src/lib/codeowners/codeowners.ts diff --git a/packages/cli/src/modules/new/lib/codeowners/index.ts b/packages/cli-module-new/src/lib/codeowners/index.ts similarity index 100% rename from packages/cli/src/modules/new/lib/codeowners/index.ts rename to packages/cli-module-new/src/lib/codeowners/index.ts diff --git a/packages/cli/src/modules/new/lib/createNewPackage.ts b/packages/cli-module-new/src/lib/createNewPackage.ts similarity index 100% rename from packages/cli/src/modules/new/lib/createNewPackage.ts rename to packages/cli-module-new/src/lib/createNewPackage.ts diff --git a/packages/cli/src/modules/new/lib/defaultTemplates.ts b/packages/cli-module-new/src/lib/defaultTemplates.ts similarity index 100% rename from packages/cli/src/modules/new/lib/defaultTemplates.ts rename to packages/cli-module-new/src/lib/defaultTemplates.ts diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli-module-new/src/lib/execution/PortableTemplater.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/PortableTemplater.ts rename to packages/cli-module-new/src/lib/execution/PortableTemplater.ts diff --git a/packages/cli/src/modules/new/lib/execution/executePortableTemplate.ts b/packages/cli-module-new/src/lib/execution/executePortableTemplate.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/executePortableTemplate.ts rename to packages/cli-module-new/src/lib/execution/executePortableTemplate.ts diff --git a/packages/cli/src/modules/new/lib/execution/index.ts b/packages/cli-module-new/src/lib/execution/index.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/index.ts rename to packages/cli-module-new/src/lib/execution/index.ts diff --git a/packages/cli/src/modules/new/lib/execution/installNewPackage.ts b/packages/cli-module-new/src/lib/execution/installNewPackage.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/installNewPackage.ts rename to packages/cli-module-new/src/lib/execution/installNewPackage.ts diff --git a/packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts b/packages/cli-module-new/src/lib/execution/writeTemplateContents.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/writeTemplateContents.test.ts rename to packages/cli-module-new/src/lib/execution/writeTemplateContents.test.ts diff --git a/packages/cli/src/modules/new/lib/execution/writeTemplateContents.ts b/packages/cli-module-new/src/lib/execution/writeTemplateContents.ts similarity index 100% rename from packages/cli/src/modules/new/lib/execution/writeTemplateContents.ts rename to packages/cli-module-new/src/lib/execution/writeTemplateContents.ts diff --git a/packages/cli/src/modules/new/lib/preparation/collectPortableTemplateInput.test.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/collectPortableTemplateInput.test.ts rename to packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.test.ts diff --git a/packages/cli/src/modules/new/lib/preparation/collectPortableTemplateInput.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/collectPortableTemplateInput.ts rename to packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts diff --git a/packages/cli/src/modules/new/lib/preparation/index.ts b/packages/cli-module-new/src/lib/preparation/index.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/index.ts rename to packages/cli-module-new/src/lib/preparation/index.ts diff --git a/packages/cli/src/modules/new/lib/preparation/loadPortableTemplate.test.ts b/packages/cli-module-new/src/lib/preparation/loadPortableTemplate.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/loadPortableTemplate.test.ts rename to packages/cli-module-new/src/lib/preparation/loadPortableTemplate.test.ts diff --git a/packages/cli/src/modules/new/lib/preparation/loadPortableTemplate.ts b/packages/cli-module-new/src/lib/preparation/loadPortableTemplate.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/loadPortableTemplate.ts rename to packages/cli-module-new/src/lib/preparation/loadPortableTemplate.ts diff --git a/packages/cli/src/modules/new/lib/preparation/loadPortableTemplateConfig.test.ts b/packages/cli-module-new/src/lib/preparation/loadPortableTemplateConfig.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/loadPortableTemplateConfig.test.ts rename to packages/cli-module-new/src/lib/preparation/loadPortableTemplateConfig.test.ts diff --git a/packages/cli/src/modules/new/lib/preparation/loadPortableTemplateConfig.ts b/packages/cli-module-new/src/lib/preparation/loadPortableTemplateConfig.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/loadPortableTemplateConfig.ts rename to packages/cli-module-new/src/lib/preparation/loadPortableTemplateConfig.ts diff --git a/packages/cli/src/modules/new/lib/preparation/resolvePackageParams.test.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/resolvePackageParams.test.ts rename to packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts diff --git a/packages/cli/src/modules/new/lib/preparation/resolvePackageParams.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/resolvePackageParams.ts rename to packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts diff --git a/packages/cli/src/modules/new/lib/preparation/selectTemplateInteractively.test.ts b/packages/cli-module-new/src/lib/preparation/selectTemplateInteractively.test.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/selectTemplateInteractively.test.ts rename to packages/cli-module-new/src/lib/preparation/selectTemplateInteractively.test.ts diff --git a/packages/cli/src/modules/new/lib/preparation/selectTemplateInteractively.ts b/packages/cli-module-new/src/lib/preparation/selectTemplateInteractively.ts similarity index 100% rename from packages/cli/src/modules/new/lib/preparation/selectTemplateInteractively.ts rename to packages/cli-module-new/src/lib/preparation/selectTemplateInteractively.ts diff --git a/packages/cli/src/modules/new/lib/tasks.ts b/packages/cli-module-new/src/lib/tasks.ts similarity index 100% rename from packages/cli/src/modules/new/lib/tasks.ts rename to packages/cli-module-new/src/lib/tasks.ts diff --git a/packages/cli/src/modules/new/lib/types.ts b/packages/cli-module-new/src/lib/types.ts similarity index 100% rename from packages/cli/src/modules/new/lib/types.ts rename to packages/cli-module-new/src/lib/types.ts diff --git a/packages/cli/src/modules/new/lib/version.test.ts b/packages/cli-module-new/src/lib/version.test.ts similarity index 97% rename from packages/cli/src/modules/new/lib/version.test.ts rename to packages/cli-module-new/src/lib/version.test.ts index 86b92e35c3..829a2f3d9e 100644 --- a/packages/cli/src/modules/new/lib/version.test.ts +++ b/packages/cli-module-new/src/lib/version.test.ts @@ -78,6 +78,9 @@ describe('createPackageVersionProvider', () => { expect(provider('@backstage/core-plugin-api')).toBe( `^${corePluginApiPkg.version}`, ); + expect(provider('@backstage/frontend-dev-utils')).toBe( + `^${packageVersions['@backstage/frontend-dev-utils']}`, + ); }); describe('with backstage protocol options', () => { diff --git a/packages/cli/src/modules/new/lib/version.ts b/packages/cli-module-new/src/lib/version.ts similarity index 65% rename from packages/cli/src/modules/new/lib/version.ts rename to packages/cli-module-new/src/lib/version.ts index 0849e8a9e8..27e63bafdf 100644 --- a/packages/cli/src/modules/new/lib/version.ts +++ b/packages/cli-module-new/src/lib/version.ts @@ -30,28 +30,30 @@ This does not create an actual dependency on these packages and does not bring i Rollup will extract the value of the version field in each package at build time without leaving any imports in place. */ -import { version as backendPluginApi } from '../../../../../../packages/backend-plugin-api/package.json'; -import { version as backendTestUtils } from '../../../../../../packages/backend-test-utils/package.json'; -import { version as catalogClient } from '../../../../../../packages/catalog-client/package.json'; -import { version as cli } from '../../../../../../packages/cli/package.json'; -import { version as config } from '../../../../../../packages/config/package.json'; -import { version as coreAppApi } from '../../../../../../packages/core-app-api/package.json'; -import { version as coreComponents } from '../../../../../../packages/core-components/package.json'; -import { version as corePluginApi } from '../../../../../../packages/core-plugin-api/package.json'; -import { version as devUtils } from '../../../../../../packages/dev-utils/package.json'; -import { version as errors } from '../../../../../../packages/errors/package.json'; -import { version as frontendDefaults } from '../../../../../../packages/frontend-defaults/package.json'; -import { version as frontendPluginApi } from '../../../../../../packages/frontend-plugin-api/package.json'; -import { version as frontendTestUtils } from '../../../../../../packages/frontend-test-utils/package.json'; -import { version as testUtils } from '../../../../../../packages/test-utils/package.json'; -import { version as scaffolderNode } from '../../../../../../plugins/scaffolder-node/package.json'; -import { version as scaffolderNodeTestUtils } from '../../../../../../plugins/scaffolder-node-test-utils/package.json'; -import { version as authBackend } from '../../../../../../plugins/auth-backend/package.json'; -import { version as authBackendModuleGuestProvider } from '../../../../../../plugins/auth-backend-module-guest-provider/package.json'; -import { version as catalogNode } from '../../../../../../plugins/catalog-node/package.json'; -import { version as theme } from '../../../../../../packages/theme/package.json'; -import { version as types } from '../../../../../../packages/types/package.json'; -import { version as backendDefaults } from '../../../../../../packages/backend-defaults/package.json'; + +import { version as backendDefaults } from '../../../backend-defaults/package.json'; +import { version as backendPluginApi } from '../../../backend-plugin-api/package.json'; +import { version as backendTestUtils } from '../../../backend-test-utils/package.json'; +import { version as catalogClient } from '../../../catalog-client/package.json'; +import { version as cli } from '../../../cli/package.json'; +import { version as config } from '../../../config/package.json'; +import { version as coreAppApi } from '../../../core-app-api/package.json'; +import { version as coreComponents } from '../../../core-components/package.json'; +import { version as corePluginApi } from '../../../core-plugin-api/package.json'; +import { version as devUtils } from '../../../dev-utils/package.json'; +import { version as errors } from '../../../errors/package.json'; +import { version as frontendDevUtils } from '../../../frontend-dev-utils/package.json'; +import { version as frontendDefaults } from '../../../frontend-defaults/package.json'; +import { version as frontendPluginApi } from '../../../frontend-plugin-api/package.json'; +import { version as frontendTestUtils } from '../../../frontend-test-utils/package.json'; +import { version as testUtils } from '../../../test-utils/package.json'; +import { version as theme } from '../../../theme/package.json'; +import { version as types } from '../../../types/package.json'; +import { version as authBackend } from '../../../../plugins/auth-backend/package.json'; +import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json'; +import { version as catalogNode } from '../../../../plugins/catalog-node/package.json'; +import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json'; +import { version as scaffolderNodeTestUtils } from '../../../../plugins/scaffolder-node-test-utils/package.json'; export const packageVersions: Record = { '@backstage/backend-defaults': backendDefaults, @@ -65,6 +67,7 @@ export const packageVersions: Record = { '@backstage/core-plugin-api': corePluginApi, '@backstage/dev-utils': devUtils, '@backstage/errors': errors, + '@backstage/frontend-dev-utils': frontendDevUtils, '@backstage/frontend-defaults': frontendDefaults, '@backstage/frontend-plugin-api': frontendPluginApi, '@backstage/frontend-test-utils': frontendTestUtils, diff --git a/packages/cli-module-test-jest/.eslintrc.js b/packages/cli-module-test-jest/.eslintrc.js new file mode 100644 index 0000000000..6f313a7a5e --- /dev/null +++ b/packages/cli-module-test-jest/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + ignorePatterns: ['config/**'], +}); diff --git a/packages/cli-module-test-jest/README.md b/packages/cli-module-test-jest/README.md new file mode 100644 index 0000000000..e56ffea2a7 --- /dev/null +++ b/packages/cli-module-test-jest/README.md @@ -0,0 +1,15 @@ +# @backstage/cli-module-test-jest + +CLI module that provides Jest-based testing commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :------------- | :---------------------------------------------------------------- | +| `package test` | Run tests, forwarding arguments to Jest, defaulting to watch mode | +| `repo test` | Run tests, forwarding arguments to Jest, defaulting to watch mode | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-test-jest/bin/backstage-cli-module-test-jest b/packages/cli-module-test-jest/bin/backstage-cli-module-test-jest new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-test-jest/bin/backstage-cli-module-test-jest @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-test-jest/catalog-info.yaml b/packages/cli-module-test-jest/catalog-info.yaml new file mode 100644 index 0000000000..c928c628f5 --- /dev/null +++ b/packages/cli-module-test-jest/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-test-jest + title: '@backstage/cli-module-test-jest' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-test-jest/cli-report.md b/packages/cli-module-test-jest/cli-report.md new file mode 100644 index 0000000000..00523eb8cf --- /dev/null +++ b/packages/cli-module-test-jest/cli-report.md @@ -0,0 +1,170 @@ +## CLI Report file for "@backstage/cli-module-test-jest" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-test-jest` + +``` +Usage: @backstage/cli-module-test-jest [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + package [command] + repo [command] +``` + +### `backstage-cli-module-test-jest package` + +``` +Usage: @backstage/cli-module-test-jest package [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + test +``` + +### `backstage-cli-module-test-jest package test` + +``` +Usage: backstage-cli-module-test-jest [--config=] + +Options: + --all + --automock + --cache + --cacheDirectory + --changedFilesWithAncestor + --changedSince + --ci + --clearCache + --clearMocks + --collectCoverage + --collectCoverageFrom + --color + --colors + --coverage + --coverageDirectory + --coveragePathIgnorePatterns + --coverageProvider + --coverageReporters + --coverageThreshold + --debug + --detectLeaks + --detectOpenHandles + --errorOnDeprecated + --filter + --findRelatedTests + --forceExit + --globalSetup + --globalTeardown + --globals + --haste + --ignoreProjects + --injectGlobals + --json + --lastCommit + --listTests + --logHeapUsage + --maxConcurrency + --moduleDirectories + --moduleFileExtensions + --moduleNameMapper + --modulePathIgnorePatterns + --modulePaths + --noStackTrace + --notify + --notifyMode + --openHandlesTimeout + --outputFile + --passWithNoTests + --preset + --prettierPath + --projects + --randomize + --reporters + --resetMocks + --resetModules + --resolver + --restoreMocks + --rootDir + --roots + --runTestsByPath + --runner + --seed + --selectProjects + --setupFiles + --setupFilesAfterEnv + --shard + --showConfig + --showSeed + --silent + --skipFilter + --snapshotSerializers + --testEnvironment, --env + --testEnvironmentOptions + --testFailureExitCode + --testLocationInResults + --testMatch + --testPathIgnorePatterns + --testPathPatterns + --testRegex + --testResultsProcessor + --testRunner + --testSequencer + --testTimeout + --transform + --transformIgnorePatterns + --unmockedModulePathPatterns + --useStderr + --verbose + --version + --waitForUnhandledRejections + --watch + --watchAll + --watchPathIgnorePatterns + --watchman + --workerThreads + -b, --bail + -c, --config + -e, --expand + -f, --onlyFailures + -h, --help + -i, --runInBand + -o, --onlyChanged + -t, --testNamePattern + -u, --updateSnapshot + -w, --maxWorkers +``` + +### `backstage-cli-module-test-jest repo` + +``` +Usage: @backstage/cli-module-test-jest repo [options] [command] [command] + +Options: + -h, --help + +Commands: + help [command] + test +``` + +### `backstage-cli-module-test-jest repo test` + +``` +Usage: @backstage/cli-module-test-jest repo test + +Options: + --jest-help + --since + --success-cache + --success-cache-dir + -h, --help +``` diff --git a/packages/cli-module-test-jest/config/getJestEnvironment.js b/packages/cli-module-test-jest/config/getJestEnvironment.js new file mode 100644 index 0000000000..aac2f79021 --- /dev/null +++ b/packages/cli-module-test-jest/config/getJestEnvironment.js @@ -0,0 +1,49 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function getJestMajorVersion() { + const jestVersion = require('jest/package.json').version; + const majorVersion = parseInt(jestVersion.split('.')[0], 10); + return majorVersion; +} + +function getJestEnvironment() { + const majorVersion = getJestMajorVersion(); + + if (majorVersion >= 30) { + try { + require.resolve('@jest/environment-jsdom-abstract'); + require.resolve('jsdom'); + } catch { + throw new Error( + 'Jest 30+ requires @jest/environment-jsdom-abstract and jsdom. ' + + 'Please install them as dev dependencies.', + ); + } + return require.resolve('./jest-environment-jsdom'); + } + try { + require.resolve('jest-environment-jsdom'); + } catch { + throw new Error( + 'Jest 29 requires jest-environment-jsdom. ' + + 'Please install it as a dev dependency.', + ); + } + return require.resolve('jest-environment-jsdom'); +} + +module.exports = { getJestMajorVersion, getJestEnvironment }; diff --git a/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js b/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js new file mode 100644 index 0000000000..9d8b76eaf5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jest-environment-jsdom/index.js @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const JSDOMEnvironment = require('@jest/environment-jsdom-abstract').default; +const jsdom = require('jsdom'); + +/** + * A custom JSDOM environment that extends the abstract base and applies + * fixes for Web API globals that are missing or incorrectly implemented + * in JSDOM. + * + * Based on https://github.com/mswjs/jest-fixed-jsdom + */ +class FixedJSDOMEnvironment extends JSDOMEnvironment { + constructor(config, context) { + super(config, context, jsdom); + + // Fix Web API globals that JSDOM doesn't properly expose + this.global.TextDecoder = TextDecoder; + this.global.TextEncoder = TextEncoder; + this.global.TextDecoderStream = TextDecoderStream; + this.global.TextEncoderStream = TextEncoderStream; + this.global.ReadableStream = ReadableStream; + + this.global.Blob = Blob; + this.global.Headers = Headers; + this.global.FormData = FormData; + this.global.Request = Request; + this.global.Response = Response; + this.global.fetch = fetch; + this.global.AbortController = AbortController; + this.global.AbortSignal = AbortSignal; + this.global.structuredClone = structuredClone; + this.global.URL = URL; + this.global.URLSearchParams = URLSearchParams; + + this.global.BroadcastChannel = BroadcastChannel; + this.global.TransformStream = TransformStream; + this.global.WritableStream = WritableStream; + + // Needed to ensure `e instanceof Error` works as expected with errors thrown from + // any of the native APIs above. Without this, the JSDOM `Error` is what the test + // code will use for comparison with `e`, which fails the instanceof check. + this.global.Error = Error; + } +} + +module.exports = FixedJSDOMEnvironment; diff --git a/packages/cli-module-test-jest/config/jest.js b/packages/cli-module-test-jest/config/jest.js new file mode 100644 index 0000000000..8a88483d42 --- /dev/null +++ b/packages/cli-module-test-jest/config/jest.js @@ -0,0 +1,422 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs-extra'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const glob = require('node:util').promisify(require('glob')); +const { version } = require('../package.json'); +const paths = require('@backstage/cli-common').findPaths(process.cwd()); +const { + getJestEnvironment, + getJestMajorVersion, +} = require('./getJestEnvironment'); + +const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs']; + +const FRONTEND_ROLES = [ + 'frontend', + 'web-library', + 'common-library', + 'frontend-plugin', + 'frontend-plugin-module', +]; + +const NODE_ROLES = [ + 'backend', + 'cli', + 'cli-module', + 'node-library', + 'backend-plugin', + 'backend-plugin-module', +]; + +const envOptions = { + oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), +}; + +try { + require.resolve('react-dom/client', { + paths: [paths.targetRoot], + }); + process.env.HAS_REACT_DOM_CLIENT = true; +} catch { + /* ignored */ +} + +/** + * A list of config keys that are valid for project-level config. + * Jest will complain if we forward any other root configuration to the projects. + * + * @type {Array} + */ +const projectConfigKeys = [ + 'automock', + 'cache', + 'cacheDirectory', + 'clearMocks', + 'collectCoverageFrom', + 'coverageDirectory', + 'coveragePathIgnorePatterns', + 'cwd', + 'dependencyExtractor', + 'detectLeaks', + 'detectOpenHandles', + 'displayName', + 'errorOnDeprecated', + 'extensionsToTreatAsEsm', + 'fakeTimers', + 'filter', + 'forceCoverageMatch', + 'globalSetup', + 'globalTeardown', + 'globals', + 'haste', + 'id', + 'injectGlobals', + 'moduleDirectories', + 'moduleFileExtensions', + 'moduleNameMapper', + 'modulePathIgnorePatterns', + 'modulePaths', + 'openHandlesTimeout', + 'preset', + 'prettierPath', + 'resetMocks', + 'resetModules', + 'resolver', + 'restoreMocks', + 'rootDir', + 'roots', + 'runner', + 'runtime', + 'sandboxInjectedGlobals', + 'setupFiles', + 'setupFilesAfterEnv', + 'skipFilter', + 'skipNodeResolution', + 'slowTestThreshold', + 'snapshotResolver', + 'snapshotSerializers', + 'snapshotFormat', + 'testEnvironment', + 'testEnvironmentOptions', + 'testMatch', + 'testLocationInResults', + 'testPathIgnorePatterns', + 'testRegex', + 'testRunner', + 'transform', + 'transformIgnorePatterns', + 'watchPathIgnorePatterns', + 'unmockedModulePathPatterns', + 'workerIdleMemoryLimit', +]; + +const transformIgnorePattern = [ + '@material-ui', + 'ajv', + 'core-js', + 'jest-.*', + 'jsdom', + 'knex', + 'react', + 'react-dom', + 'highlight\\.js', + 'prismjs', + 'json-schema', + 'react-use/lib', + 'typescript', +].join('|'); + +// Provides additional config that's based on the role of the target package +function getRoleConfig(role, pkgJson) { + // Only Node.js package roles support native ESM modules, frontend and common + // packages are always transpiled to CommonJS. + const moduleOpts = NODE_ROLES.includes(role) + ? { + module: { + ignoreDynamic: true, + exportInteropAnnotation: true, + }, + } + : undefined; + + const transform = { + '\\.(mjs|cjs|js)$': [ + require.resolve('./jestSwcTransform'), + { + ...moduleOpts, + jsc: { + parser: { + syntax: 'ecmascript', + }, + }, + }, + ], + '\\.jsx$': [ + require.resolve('./jestSwcTransform'), + { + jsc: { + parser: { + syntax: 'ecmascript', + jsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + '\\.(mts|cts|ts)$': [ + require.resolve('./jestSwcTransform'), + { + ...moduleOpts, + jsc: { + parser: { + syntax: 'typescript', + }, + }, + }, + ], + '\\.tsx$': [ + require.resolve('./jestSwcTransform'), + { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + '\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$': + require.resolve('./jestFileTransform.js'), + '\\.(yaml)$': require.resolve('./jestYamlTransform'), + }; + if (FRONTEND_ROLES.includes(role)) { + return { + testEnvironment: getJestEnvironment(), + // The caching module loader is only used to speed up frontend tests, + // as it breaks real dynamic imports of ESM modules. + runtime: envOptions.oldTests + ? undefined + : require.resolve('./jestCachingModuleLoader'), + transform, + }; + } + return { + testEnvironment: require.resolve('jest-environment-node'), + moduleFileExtensions: [...SRC_EXTS, 'json', 'node'], + // Jest doesn't let us dynamically detect type=module per transformed file, + // so we have to assume that if the entry point is ESM, all TS files are + // ESM. + // + // This means you can't switch a package to type=module until all of its + // monorepo dependencies are also type=module or does not contain any .ts + // files. + extensionsToTreatAsEsm: + pkgJson.type === 'module' ? ['.ts', '.mts'] : ['.mts'], + transform, + }; +} + +async function getProjectConfig(targetPath, extraConfig, extraOptions) { + const configJsPath = path.resolve(targetPath, 'jest.config.js'); + const configTsPath = path.resolve(targetPath, 'jest.config.ts'); + // If the package has it's own jest config, we use that instead. + if (await fs.pathExists(configJsPath)) { + return require(configJsPath); + } else if (await fs.pathExists(configTsPath)) { + return require(configTsPath); + } + + // Jest config can be defined both in the root package.json and within each package. The root config + // gets forwarded to us through the `extraConfig` parameter, while the package config is read here. + // If they happen to be the same the keys will simply override each other. + // The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined. + const pkgJson = await fs.readJson(path.resolve(targetPath, 'package.json')); + + const options = { + ...extraConfig, + rootDir: path.resolve(targetPath, 'src'), + moduleNameMapper: { + '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), + }, + + // A bit more opinionated + testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`], + + transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`], + ...getRoleConfig(pkgJson.backstage?.role, pkgJson), + }; + + options.setupFilesAfterEnv = options.setupFilesAfterEnv || []; + + if ( + extraOptions.rejectFrontendNetworkRequests && + FRONTEND_ROLES.includes(pkgJson.backstage?.role) + ) { + // By adding this first we ensure that it's possible to for example override + // fetch with a mock in a custom setup file + options.setupFilesAfterEnv.unshift( + require.resolve('./jestRejectNetworkRequests.js'), + ); + } + + if ( + options.testEnvironment === getJestEnvironment() && + getJestMajorVersion() < 30 // Only needed when not running the custom env for Jest 30+ + ) { + // FIXME https://github.com/jsdom/jsdom/issues/1724 + options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill')); + } + + // Use src/setupTests.* as the default location for configuring test env + for (const ext of SRC_EXTS) { + if (fs.existsSync(path.resolve(targetPath, `src/setupTests.${ext}`))) { + options.setupFilesAfterEnv.push(`/setupTests.${ext}`); + break; + } + } + + const config = Object.assign(options, pkgJson.jest); + + // The config id is a cache key that lets us share the jest cache across projects. + // If no explicit id was configured, generated one based on the configuration. + if (!config.id) { + const configHash = crypto + .createHash('sha256') + .update(version) + .update(Buffer.alloc(1)) + .update(JSON.stringify(config.transform).replaceAll(paths.targetRoot, '')) + .digest('hex'); + config.id = `backstage_cli_${configHash}`; + } + + return config; +} + +// This loads the root jest config, which in turn will either refer to a single +// configuration for the current package, or a collection of configurations for +// the target workspace packages +async function getRootConfig() { + const rootPkgJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + + const baseCoverageConfig = { + coverageDirectory: paths.resolveTarget('coverage'), + coverageProvider: envOptions.oldTests ? 'v8' : 'babel', + collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'], + }; + + const { rejectFrontendNetworkRequests, ...rootOptions } = + rootPkgJson.jest ?? {}; + const extraRootOptions = { + rejectFrontendNetworkRequests, + }; + + const ws = rootPkgJson.workspaces; + const workspacePatterns = Array.isArray(ws) ? ws : ws?.packages; + + // Check if we're running within a specific monorepo package. In that case just get the single project config. + if (!workspacePatterns || paths.targetRoot !== paths.targetDir) { + return getProjectConfig( + paths.targetDir, + { + ...baseCoverageConfig, + ...rootOptions, + }, + extraRootOptions, + ); + } + + const globalRootConfig = { ...baseCoverageConfig }; + const globalProjectConfig = {}; + + for (const [key, value] of Object.entries(rootOptions)) { + if (projectConfigKeys.includes(key)) { + globalProjectConfig[key] = value; + } else { + globalRootConfig[key] = value; + } + } + + // If the target package is a workspace root, we find all packages in the + // workspace and load those in as separate jest projects instead. + const projectPaths = await Promise.all( + workspacePatterns.map(pattern => + glob(path.join(paths.targetRoot, pattern)), + ), + ).then(_ => _.flat()); + + let projects = await Promise.all( + projectPaths.flat().map(async projectPath => { + const packagePath = path.resolve(projectPath, 'package.json'); + if (!(await fs.pathExists(packagePath))) { + return undefined; + } + + // We check for the presence of "backstage-cli test" in the package test + // script to determine whether a given package should be tested + const packageData = await fs.readJson(packagePath); + const testScript = packageData.scripts && packageData.scripts.test; + const isSupportedTestScript = + testScript?.includes('backstage-cli test') || + testScript?.includes('backstage-cli package test'); + if (testScript && isSupportedTestScript) { + return await getProjectConfig( + projectPath, + { + ...globalProjectConfig, + displayName: packageData.name, + }, + extraRootOptions, + ); + } + + return undefined; + }), + ).then(cs => cs.filter(Boolean)); + + const cache = global.__backstageCli_jestSuccessCache; + if (cache) { + projects = await cache.filterConfigs(projects, globalRootConfig); + } + const watchProjectFilter = global.__backstageCli_watchProjectFilter; + if (watchProjectFilter) { + projects = await watchProjectFilter.filter(projects); + } + + return { + rootDir: paths.targetRoot, + projects, + testResultsProcessor: cache + ? require.resolve('./jestCacheResultProcessor.cjs') + : undefined, + ...globalRootConfig, + }; +} + +module.exports = getRootConfig(); diff --git a/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs b/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs new file mode 100644 index 0000000000..4af401d8b5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestCacheResultProcessor.cjs @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = async results => { + const cache = global.__backstageCli_jestSuccessCache; + if (cache) { + await cache.reportResults(results); + } + return results; +}; diff --git a/packages/cli-module-test-jest/config/jestCachingModuleLoader.js b/packages/cli-module-test-jest/config/jestCachingModuleLoader.js new file mode 100644 index 0000000000..5652ff67e7 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestCachingModuleLoader.js @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// 'jest-runtime' is included with jest and should be kept in sync with the installed jest version +// eslint-disable-next-line @backstage/no-undeclared-imports +const { default: JestRuntime } = require('jest-runtime'); + +module.exports = class CachingJestRuntime extends JestRuntime { + constructor(config, ...restArgs) { + super(config, ...restArgs); + this.allowLoadAsEsm = config.extensionsToTreatAsEsm.includes('.mts'); + } + + // Unfortunately we need to use this unstable API to make sure that .js files + // are only loaded as modules where ESM is supported, i.e. Node.js packages. + unstable_shouldLoadAsEsm(path, ...restArgs) { + if (!this.allowLoadAsEsm) { + return false; + } + return super.unstable_shouldLoadAsEsm(path, ...restArgs); + } +}; diff --git a/packages/cli-module-test-jest/config/jestFileTransform.js b/packages/cli-module-test-jest/config/jestFileTransform.js new file mode 100644 index 0000000000..ad1c37a4f5 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestFileTransform.js @@ -0,0 +1,44 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.icon\.svg$/)) { + return { + code: `const React = require('react'); + const SvgIcon = require('@material-ui/core/SvgIcon').default; + module.exports = { + __esModule: true, + default: props => React.createElement(SvgIcon, props, { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }) + };`, + }; + } + + return { code: `module.exports = ${assetFilename};` }; + }, +}; diff --git a/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js b/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js new file mode 100644 index 0000000000..f6f189a60c --- /dev/null +++ b/packages/cli-module-test-jest/config/jestRejectNetworkRequests.js @@ -0,0 +1,70 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const http = require('node:http'); +const https = require('node:https'); + +const errorMessage = 'Network requests are not allowed in tests'; + +const origHttpAgent = http.globalAgent; +const origHttpsAgent = https.globalAgent; +const origFetch = global.fetch; +const origXMLHttpRequest = global.XMLHttpRequest; + +http.globalAgent = new http.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +https.globalAgent = new https.Agent({ + lookup() { + throw new Error(errorMessage); + }, +}); + +const BLOCKING_FETCH_SYMBOL = Symbol.for( + 'backstage.jestRejectNetworkRequests.blockingFetch', +); + +if (global.fetch) { + const blockingFetch = async (input, init) => { + // If global.fetch still has our marker, block the request + if (global.fetch[BLOCKING_FETCH_SYMBOL]) { + throw new Error(errorMessage); + } + // MSW (or something else) wrapped us - pass through + return origFetch(input, init); + }; + blockingFetch[BLOCKING_FETCH_SYMBOL] = true; + global.fetch = blockingFetch; +} + +if (global.XMLHttpRequest) { + global.XMLHttpRequest = class { + constructor() { + throw new Error(errorMessage); + } + }; +} + +// Reset overrides after each suite to make sure we don't pollute the test environment +afterAll(() => { + http.globalAgent = origHttpAgent; + https.globalAgent = origHttpsAgent; + global.fetch = origFetch; + global.XMLHttpRequest = origXMLHttpRequest; +}); diff --git a/packages/cli-module-test-jest/config/jestSucraseTransform.js b/packages/cli-module-test-jest/config/jestSucraseTransform.js new file mode 100644 index 0000000000..621b9ee297 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestSucraseTransform.js @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { createHash } = require('node:crypto'); +const { transform } = require('sucrase'); +const sucrasePkg = require('sucrase/package.json'); + +const ESM_REGEX = /\b(?:import|export)\b/; + +function createTransformer(config) { + const process = (source, filePath) => { + let transforms; + + if (filePath.endsWith('.esm.js')) { + transforms = ['imports']; + } else if (filePath.endsWith('.js')) { + // This is a very rough filter to avoid transforming things that we quickly + // can be sure are definitely not ESM modules. + if (ESM_REGEX.test(source)) { + transforms = ['imports', 'jsx']; // JSX within .js is currently allowed + } + } else if (filePath.endsWith('.jsx')) { + transforms = ['jsx', 'imports']; + } else if (filePath.endsWith('.ts')) { + transforms = ['typescript', 'imports']; + } else if (filePath.endsWith('.tsx')) { + transforms = ['typescript', 'jsx', 'imports']; + } + + // Only apply the jest transform to the test files themselves + if (transforms && filePath.includes('.test.')) { + transforms.push('jest'); + } + + if (transforms) { + const { code, sourceMap: map } = transform(source, { + transforms, + filePath, + disableESTransforms: true, + sourceMapOptions: { + compiledFilename: filePath, + }, + }); + if (config.enableSourceMaps) { + const b64 = Buffer.from(JSON.stringify(map), 'utf8').toString('base64'); + const suffix = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`; + // Include both inline and object source maps, as inline source maps are + // needed for support of some editor integrations. + return { code: `${code}\n${suffix}`, map }; + } + // We only return the `map` result if source maps are enabled, as they + // have a negative impact on the coverage accuracy. + return { code }; + } + + return { code: source }; + }; + + const getCacheKey = sourceText => { + return createHash('sha256') + .update(sourceText) + .update(Buffer.alloc(1)) + .update(sucrasePkg.version) + .update(Buffer.alloc(1)) + .update(JSON.stringify(config)) + .update(Buffer.alloc(1)) + .update('1') // increment whenever the transform logic in this file changes + .digest('hex'); + }; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/config/jestSwcTransform.js b/packages/cli-module-test-jest/config/jestSwcTransform.js new file mode 100644 index 0000000000..4183349554 --- /dev/null +++ b/packages/cli-module-test-jest/config/jestSwcTransform.js @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const { createTransformer: createSwcTransformer } = require('@swc/jest'); + +const ESM_REGEX = /\b(?:import|export)\b/; + +function createTransformer(config) { + const swcTransformer = createSwcTransformer({ + inputSourceMap: false, + ...config, + }); + const process = (source, filePath, jestOptions) => { + // Skip transformation of .js files without ESM syntax, we never transform from CJS to ESM + if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { + return { code: source }; + } + + // Skip transformation of .mjs files, they should only be used if ESM support is available + if (jestOptions.supportsStaticESM && filePath.endsWith('.mjs')) { + return { code: source }; + } + + return swcTransformer.process(source, filePath, jestOptions); + }; + + const getCacheKey = swcTransformer.getCacheKey; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/config/jestYamlTransform.js b/packages/cli-module-test-jest/config/jestYamlTransform.js new file mode 100644 index 0000000000..7a05274add --- /dev/null +++ b/packages/cli-module-test-jest/config/jestYamlTransform.js @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const yaml = require('yaml'); +const crypto = require('node:crypto'); + +function createTransformer(config) { + const process = source => { + const json = JSON.stringify(yaml.parse(source), null, 2); + return { code: `module.exports = ${json}`, map: null }; + }; + + const getCacheKey = sourceText => { + return crypto + .createHash('sha256') + .update(sourceText) + .update(Buffer.alloc(1)) + .update(JSON.stringify(config)) + .update(Buffer.alloc(1)) + .update('1') // increment whenever the transform logic in this file changes + .digest('hex'); + }; + + return { process, getCacheKey }; +} + +module.exports = { createTransformer }; diff --git a/packages/cli-module-test-jest/package.json b/packages/cli-module-test-jest/package.json new file mode 100644 index 0000000000..6d7811f484 --- /dev/null +++ b/packages/cli-module-test-jest/package.json @@ -0,0 +1,69 @@ +{ + "name": "@backstage/cli-module-test-jest", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-test-jest" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin", + "config" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "@swc/core": "^1.15.6", + "@swc/jest": "^0.2.39", + "cleye": "^2.3.0", + "cross-fetch": "^4.0.0", + "fs-extra": "^11.2.0", + "glob": "^7.1.7", + "jest-css-modules": "^2.1.0", + "sucrase": "^3.20.2", + "yargs": "^16.2.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "peerDependencies": { + "@jest/environment-jsdom-abstract": "^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-environment-jsdom": "*", + "jsdom": "^27.1.0" + }, + "peerDependenciesMeta": { + "@jest/environment-jsdom-abstract": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jsdom": { + "optional": true + } + }, + "bin": "bin/backstage-cli-module-test-jest" +} diff --git a/packages/cli-module-test-jest/report.api.md b/packages/cli-module-test-jest/report.api.md new file mode 100644 index 0000000000..816df9dcf1 --- /dev/null +++ b/packages/cli-module-test-jest/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-test-jest" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/test/commands/package/test.ts b/packages/cli-module-test-jest/src/commands/package/test.ts similarity index 93% rename from packages/cli/src/modules/test/commands/package/test.ts rename to packages/cli-module-test-jest/src/commands/package/test.ts index 687b66dbfd..b260601f8e 100644 --- a/packages/cli/src/modules/test/commands/package/test.ts +++ b/packages/cli-module-test-jest/src/commands/package/test.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { runCheck, findOwnPaths } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import { findOwnPaths, runCheck } from '@backstage/cli-common'; +import type { CliCommandContext } from '@backstage/cli-node'; function includesAnyOf(hayStack: string[], ...needles: string[]) { for (const needle of needles) { @@ -26,7 +26,7 @@ function includesAnyOf(hayStack: string[], ...needles: string[]) { return false; } -export default async ({ args }: CommandContext) => { +export default async ({ args }: CliCommandContext) => { // Only include our config if caller isn't passing their own config if (!includesAnyOf(args, '-c', '--config')) { /* eslint-disable-next-line no-restricted-syntax */ @@ -96,5 +96,6 @@ export default async ({ args }: CommandContext) => { process.exit(1); } + // eslint-disable-next-line @backstage/no-undeclared-imports await require('jest').run(args); }; diff --git a/packages/cli/src/modules/test/commands/repo/test.test.ts b/packages/cli-module-test-jest/src/commands/repo/test.test.ts similarity index 100% rename from packages/cli/src/modules/test/commands/repo/test.test.ts rename to packages/cli-module-test-jest/src/commands/repo/test.test.ts diff --git a/packages/cli/src/modules/test/commands/repo/test.ts b/packages/cli-module-test-jest/src/commands/repo/test.ts similarity index 99% rename from packages/cli/src/modules/test/commands/repo/test.ts rename to packages/cli-module-test-jest/src/commands/repo/test.ts index 1c39415192..806d88965b 100644 --- a/packages/cli/src/modules/test/commands/repo/test.ts +++ b/packages/cli-module-test-jest/src/commands/repo/test.ts @@ -25,13 +25,13 @@ import { relative as relativePath } from 'node:path'; import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node'; import { + findOwnPaths, runCheck, runOutput, targetPaths, - findOwnPaths, isChildPath, } from '@backstage/cli-common'; -import type { CommandContext } from '../../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; type JestProject = { displayName: string; @@ -131,7 +131,7 @@ export function createFlagFinder(args: string[]) { }; } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const testGlobal = global as TestGlobal; for (const flag of ['successCache', 'successCacheDir', 'jestHelp']) { diff --git a/packages/cli/src/modules/test/index.ts b/packages/cli-module-test-jest/src/index.ts similarity index 88% rename from packages/cli/src/modules/test/index.ts rename to packages/cli-module-test-jest/src/index.ts index 627648332b..b2060fa89e 100644 --- a/packages/cli/src/modules/test/index.ts +++ b/packages/cli-module-test-jest/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'test', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['repo', 'test'], diff --git a/packages/cli-module-translations/.eslintrc.js b/packages/cli-module-translations/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-translations/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-translations/README.md b/packages/cli-module-translations/README.md new file mode 100644 index 0000000000..1f415ca975 --- /dev/null +++ b/packages/cli-module-translations/README.md @@ -0,0 +1,15 @@ +# @backstage/cli-module-translations + +CLI module that provides translation management commands for the Backstage CLI. + +## Commands + +| Command | Description | +| :-------------------- | :------------------------------------------------------------------------------------ | +| `translations export` | Export translation messages from an app and all of its frontend plugins to JSON files | +| `translations import` | Generate translation resource wiring from translated JSON files | + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-module-translations/bin/backstage-cli-module-translations b/packages/cli-module-translations/bin/backstage-cli-module-translations new file mode 100755 index 0000000000..68bdc76a4a --- /dev/null +++ b/packages/cli-module-translations/bin/backstage-cli-module-translations @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('node:path'); + +/* eslint-disable-next-line no-restricted-syntax */ +const isLocal = require('node:fs').existsSync( + path.resolve(__dirname, '../src'), +); + +if (isLocal) { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} + +const { runCliModule } = require('@backstage/cli-node'); +const cliModule = require(isLocal ? '../src/index' : '..').default; +const pkg = require('../package.json'); +runCliModule({ module: cliModule, name: pkg.name, version: pkg.version }); diff --git a/packages/cli-module-translations/catalog-info.yaml b/packages/cli-module-translations/catalog-info.yaml new file mode 100644 index 0000000000..a2f21fc649 --- /dev/null +++ b/packages/cli-module-translations/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-translations + title: '@backstage/cli-module-translations' + description: CLI module for Backstage CLI +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-translations/cli-report.md b/packages/cli-module-translations/cli-report.md new file mode 100644 index 0000000000..bf2ff28001 --- /dev/null +++ b/packages/cli-module-translations/cli-report.md @@ -0,0 +1,53 @@ +## CLI Report file for "@backstage/cli-module-translations" + +> Do not edit this file. It is a report generated by `yarn build:api-reports` + +### `backstage-cli-module-translations` + +``` +Usage: @backstage/cli-module-translations [options] [command] + +Options: + -V, --version + -h, --help + +Commands: + help [command] + translations [command] +``` + +### `backstage-cli-module-translations translations` + +``` +Usage: @backstage/cli-module-translations translations [options] [command] [command] + +Options: + -h, --help + +Commands: + export + help [command] + import +``` + +### `backstage-cli-module-translations translations export` + +``` +Usage: @backstage/cli-module-translations translations export + +Options: + --output + --pattern + -h, --help +``` + +### `backstage-cli-module-translations translations import` + +``` +Usage: @backstage/cli-module-translations translations import + +Options: + --input + --output + -h, --help +``` diff --git a/packages/cli-module-translations/package.json b/packages/cli-module-translations/package.json new file mode 100644 index 0000000000..86ef2b7ca4 --- /dev/null +++ b/packages/cli-module-translations/package.json @@ -0,0 +1,46 @@ +{ + "name": "@backstage/cli-module-translations", + "version": "0.0.0", + "description": "CLI module for Backstage CLI", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-translations" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", + "cleye": "^2.3.0", + "fs-extra": "^11.2.0", + "ts-morph": "^24.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/fs-extra": "^11.0.0" + }, + "bin": "bin/backstage-cli-module-translations" +} diff --git a/packages/cli-module-translations/report.api.md b/packages/cli-module-translations/report.api.md new file mode 100644 index 0000000000..5e5864bea5 --- /dev/null +++ b/packages/cli-module-translations/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-translations" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli/src/modules/translations/commands/export.ts b/packages/cli-module-translations/src/commands/export.ts similarity index 97% rename from packages/cli/src/modules/translations/commands/export.ts rename to packages/cli-module-translations/src/commands/export.ts index b6471f10f4..a1d2eb2dae 100644 --- a/packages/cli/src/modules/translations/commands/export.ts +++ b/packages/cli-module-translations/src/commands/export.ts @@ -33,9 +33,9 @@ import { formatMessagePath, validatePattern, } from '../lib/messageFilePath'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { output, pattern }, } = cli( diff --git a/packages/cli/src/modules/translations/commands/import.ts b/packages/cli-module-translations/src/commands/import.ts similarity index 98% rename from packages/cli/src/modules/translations/commands/import.ts rename to packages/cli-module-translations/src/commands/import.ts index 155724dd8c..35a071c4fb 100644 --- a/packages/cli/src/modules/translations/commands/import.ts +++ b/packages/cli-module-translations/src/commands/import.ts @@ -28,7 +28,7 @@ import { createMessagePathParser, formatMessagePath, } from '../lib/messageFilePath'; -import type { CommandContext } from '../../../wiring/types'; +import type { CliCommandContext } from '@backstage/cli-node'; interface ManifestRefEntry { package: string; @@ -41,7 +41,7 @@ interface Manifest { refs: Record; } -export default async ({ args, info }: CommandContext) => { +export default async ({ args, info }: CliCommandContext) => { const { flags: { input, output }, } = cli( diff --git a/packages/cli/src/modules/translations/index.ts b/packages/cli-module-translations/src/index.ts similarity index 88% rename from packages/cli/src/modules/translations/index.ts rename to packages/cli-module-translations/src/index.ts index 43263831d5..e075dc61ae 100644 --- a/packages/cli/src/modules/translations/index.ts +++ b/packages/cli-module-translations/src/index.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createCliPlugin } from '../../wiring/factory'; +import { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; -export default createCliPlugin({ - pluginId: 'translations', +export default createCliModule({ + packageJson, init: async reg => { reg.addCommand({ path: ['translations', 'export'], diff --git a/packages/cli/src/modules/translations/lib/discoverPackages.ts b/packages/cli-module-translations/src/lib/discoverPackages.ts similarity index 100% rename from packages/cli/src/modules/translations/lib/discoverPackages.ts rename to packages/cli-module-translations/src/lib/discoverPackages.ts diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts b/packages/cli-module-translations/src/lib/extractTranslations.test.ts similarity index 88% rename from packages/cli/src/modules/translations/lib/extractTranslations.test.ts rename to packages/cli-module-translations/src/lib/extractTranslations.test.ts index 20ff05b69e..13a94f3a0a 100644 --- a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts +++ b/packages/cli-module-translations/src/lib/extractTranslations.test.ts @@ -23,11 +23,11 @@ import { describe('extractTranslations', () => { it('extracts translation refs from the org plugin', () => { const project = createTranslationProject( - resolvePath(__dirname, '../../../../../../tsconfig.json'), + resolvePath(__dirname, '../../../../tsconfig.json'), ); const sourceFile = project.addSourceFileAtPath( - resolvePath(__dirname, '../../../../../..', 'plugins/org/src/alpha.tsx'), + resolvePath(__dirname, '../../../..', 'plugins/org/src/alpha.tsx'), ); const refs = extractTranslationRefsFromSourceFile( @@ -59,12 +59,12 @@ describe('extractTranslations', () => { it('ignores non-TranslationRef exports', () => { const project = createTranslationProject( - resolvePath(__dirname, '../../../../../../tsconfig.json'), + resolvePath(__dirname, '../../../../tsconfig.json'), ); // The main entry of org plugin exports components but no translation ref const sourceFile = project.addSourceFileAtPath( - resolvePath(__dirname, '../../../../../..', 'plugins/org/src/index.ts'), + resolvePath(__dirname, '../../../..', 'plugins/org/src/index.ts'), ); const refs = extractTranslationRefsFromSourceFile( @@ -78,13 +78,13 @@ describe('extractTranslations', () => { it('extracts from the test fixtures translation ref', () => { const project = createTranslationProject( - resolvePath(__dirname, '../../../../../../tsconfig.json'), + resolvePath(__dirname, '../../../../tsconfig.json'), ); const sourceFile = project.addSourceFileAtPath( resolvePath( __dirname, - '../../../../../..', + '../../../..', 'packages/frontend-plugin-api/src/translation/__fixtures__/refs.ts', ), ); diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.ts b/packages/cli-module-translations/src/lib/extractTranslations.ts similarity index 100% rename from packages/cli/src/modules/translations/lib/extractTranslations.ts rename to packages/cli-module-translations/src/lib/extractTranslations.ts diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.test.ts b/packages/cli-module-translations/src/lib/messageFilePath.test.ts similarity index 100% rename from packages/cli/src/modules/translations/lib/messageFilePath.test.ts rename to packages/cli-module-translations/src/lib/messageFilePath.test.ts diff --git a/packages/cli/src/modules/translations/lib/messageFilePath.ts b/packages/cli-module-translations/src/lib/messageFilePath.ts similarity index 100% rename from packages/cli/src/modules/translations/lib/messageFilePath.ts rename to packages/cli-module-translations/src/lib/messageFilePath.ts diff --git a/packages/cli-node/.eslintrc.js b/packages/cli-node/.eslintrc.js index e2a53a6ad2..7e7c302633 100644 --- a/packages/cli-node/.eslintrc.js +++ b/packages/cli-node/.eslintrc.js @@ -1 +1,19 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +module.exports = { + ...require('@backstage/cli/config/eslint-factory')(__dirname), + ignorePatterns: ['config/**'], +}; diff --git a/packages/cli-node/config/nodeTransform.cjs b/packages/cli-node/config/nodeTransform.cjs new file mode 100644 index 0000000000..15bcdba9bc --- /dev/null +++ b/packages/cli-node/config/nodeTransform.cjs @@ -0,0 +1,87 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { pathToFileURL } = require('node:url'); +const { transformSync } = require('@swc/core'); +const { addHook } = require('pirates'); +const { Module } = require('node:module'); + +// This hooks into module resolution and overrides imports of packages that +// exist in the linked workspace to instead be resolved from the linked workspace. +if (process.env.BACKSTAGE_CLI_LINKED_WORKSPACE) { + const { join: joinPath } = require('node:path'); + const { getPackagesSync } = require('@manypkg/get-packages'); + const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( + process.env.BACKSTAGE_CLI_LINKED_WORKSPACE, + ); + + // Matches all packages in the linked workspaces, as well as sub-path exports from them + const replacementRegex = new RegExp( + `^(?:${linkedPackages + .map(pkg => pkg.packageJson.name) + .join('|')})(?:/.*)?$`, + ); + + const origLoad = Module._load; + Module._load = function requireHook(request, parent) { + if (!replacementRegex.test(request)) { + return origLoad.call(this, request, parent); + } + + // The package import that we're overriding will always existing in the root + // node_modules of the linked workspace, so it's enough to override the + // parent paths with that single entry + return origLoad.call(this, request, { + ...parent, + paths: [joinPath(linkedRoot.dir, 'node_modules')], + }); + }; +} + +addHook( + (code, filename) => { + const transformed = transformSync(code, { + filename, + sourceMaps: 'inline', + module: { + type: 'commonjs', + ignoreDynamic: true, + }, + jsc: { + target: 'es2023', + parser: { + syntax: 'typescript', + }, + }, + }); + process.send?.({ type: 'watch', path: filename }); + return transformed.code; + }, + { extensions: ['.ts', '.cts'], ignoreNodeModules: true }, +); + +addHook( + (code, filename) => { + process.send?.({ type: 'watch', path: filename }); + return code; + }, + { extensions: ['.js', '.cjs'], ignoreNodeModules: true }, +); + +// Register module hooks, used by "type": "module" in package.json, .mjs and +// .mts files, as well as dynamic import(...)s, although dynamic imports will be +// handled be the CommonJS hooks in this file if what it points to is CommonJS. +Module.register('./nodeTransformHooks.mjs', pathToFileURL(__filename)); diff --git a/packages/cli-node/config/nodeTransformHooks.mjs b/packages/cli-node/config/nodeTransformHooks.mjs new file mode 100644 index 0000000000..592867e57a --- /dev/null +++ b/packages/cli-node/config/nodeTransformHooks.mjs @@ -0,0 +1,294 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dirname, extname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { transformFile } from '@swc/core'; +import { isBuiltin } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +// @ts-check + +// No explicit file extension, no type in package.json +const DEFAULT_MODULE_FORMAT = 'commonjs'; + +// Source file extensions to look for when using bundle resolution strategy +const SRC_EXTS = ['.ts', '.js']; +const TS_EXTS = ['.ts', '.mts', '.cts']; +const moduleTypeTable = { + '.mjs': 'module', + '.mts': 'module', + '.cjs': 'commonjs', + '.cts': 'commonjs', + '.ts': undefined, + '.js': undefined, +}; + +/** @type {import('module').ResolveHook} */ +export async function resolve(specifier, context, nextResolve) { + // Built-in modules are handled by the default resolver + if (isBuiltin(specifier)) { + return nextResolve(specifier, context); + } + + const ext = extname(specifier); + + // Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below. + if (ext === '.json' && !context.importAttributes?.type) { + const jsonResult = await nextResolve(specifier, context); + return { + ...jsonResult, + format: 'commonjs', + importAttributes: { type: 'json' }, + }; + } + + // Anything else with an explicit extension is handled by the default + // resolver, except that we help determine the module type where needed. + if (ext !== '') { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // Other external modules are handled by the default resolver, but again we + // help determine the module type where needed. + if (!specifier.startsWith('.')) { + return withDetectedModuleType(await nextResolve(specifier, context)); + } + + // The rest of this function handles the case of resolving imports that do not + // specify any extension and might point to a directory with an `index.*` + // file. We resolve those using the same logic as most JS bundlers would, with + // the addition of checking if there's an explicit module format listed in the + // closest `package.json` file. + // + // We use a bundle resolution strategy in order to keep code consistent across + // Backstage codebases that contains code both for Web and Node.js, and to + // support packages with common code that can be used in both environments. + try { + // This is expected to throw, but in the event that this module specifier is + // supported we prefer to use the default resolver. + return await nextResolve(specifier, context); + } catch (error) { + if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { + const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`; + const resolved = await resolveWithoutExt(spec, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } else if (error.code === 'ERR_MODULE_NOT_FOUND') { + const resolved = await resolveWithoutExt(specifier, context, nextResolve); + if (resolved) { + return withDetectedModuleType(resolved); + } + } + + // Unexpected error or no resolution found + throw error; + } +} + +/** + * Populates the `format` field in the resolved object based on the closest `package.json` file. + * + * @param {import('module').ResolveFnOutput} resolved + * @returns {Promise} + */ +async function withDetectedModuleType(resolved) { + // Already has an explicit format + if (resolved.format) { + return resolved; + } + // Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default. + if (resolved.format === null) { + return { ...resolved, format: DEFAULT_MODULE_FORMAT }; + } + + const ext = extname(resolved.url); + + const explicitFormat = moduleTypeTable[ext]; + if (explicitFormat) { + return { + ...resolved, + format: explicitFormat, + }; + } + + // Under normal circumstances .js files should reliably have a format and so + // we should only reach this point for .ts files. However, if additional + // custom loaders are being used the format may not be detected for .js files + // either. As such we don't restrict the file format at this point. + + // TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring + const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url)); + if (!packageJsonPath) { + return resolved; + } + + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + return { + ...resolved, + format: packageJson.type ?? DEFAULT_MODULE_FORMAT, + }; +} + +/** + * Find the closest package.json file from the given path. + * + * TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable. + * @param {string} startPath + * @returns {Promise} + */ +async function findPackageJSON(startPath) { + let path = startPath; + + // Some confidence check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + if (existsSync(packagePath)) { + return packagePath; + } + + const newPath = dirname(path); + if (newPath === path) { + return undefined; + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for package.json at ${startPath}`, + ); +} + +/** @type {import('module').ResolveHook} */ +async function resolveWithoutExt(specifier, context, nextResolve) { + for (const tryExt of SRC_EXTS) { + try { + const resolved = await nextResolve(specifier + tryExt, { + ...context, + format: 'commonjs', + }); + return { + ...resolved, + format: moduleTypeTable[tryExt] ?? resolved.format, + }; + } catch { + /* ignore */ + } + } + return undefined; +} + +/** @type {import('module').LoadHook} */ +export async function load(url, context, nextLoad) { + // Non-file URLs are handled by the default loader + if (!url.startsWith('file://')) { + return nextLoad(url, context); + } + + // JSON files loaded as CommonJS are handled by this custom loader, because + // the default one doesn't work. For JSON loading to work we'd need the + // synchronous hooks that aren't supported yet, or avoid using the CommonJS + // compatibility. + if ( + context.format === 'commonjs' && + context.importAttributes?.type === 'json' + ) { + try { + // TODO(Rugvip): Make sure this is valid JSON + const content = await readFile(fileURLToPath(url), 'utf8'); + return { + source: `module.exports = (${content})`, + format: 'commonjs', + shortCircuit: true, + }; + } catch { + // Let the default loader generate the error + return nextLoad(url, context); + } + } + + const ext = extname(url); + + // Non-TS files are handled by the default loader + if (!TS_EXTS.includes(ext)) { + return nextLoad(url, context); + } + + const format = context.format ?? DEFAULT_MODULE_FORMAT; + + // We have two choices at this point, we can either transform CommonJS files + // and return the transformed source code, or let the default loader handle + // them. If we transform them ourselves we will enter CommonJS compatibility + // mode in the new module system in Node.js, this effectively means all + // CommonJS loaded via `require` calls from this point will all be treated as + // if it was loaded via `import` calls from modules. + // + // The CommonJS compatibility layer will try to identify named exports and + // make them available directly, which is convenient as it avoids things like + // `import(...).then(m => m.default.foo)`, allowing you to instead write + // `import(...).then(m => m.foo)`. The compatibility layer doesn't always work + // all that well though, and can lead to module loading issues in many cases, + // especially for older code. + + // This `if` block opts-out of using CommonJS compatibility mode by default, + // and instead leaves it to our existing loader to transform CommonJS. We do + // however use compatibility mode for the more explicit .cts file extension, + // allows for a way to opt-in to the new behavior. + // + // TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead + if (format === 'commonjs' && ext !== '.cts') { + return nextLoad(url, { ...context, format }); + } + + // If the Node.js version we're running supports TypeScript, i.e. type + // stripping, we hand over to the default loader. This is done for all cases + // except if we're loading a .ts file that's been resolved to CommonJS format. + // This is because these files aren't actually CommonJS in the Backstage build + // system, and need to be transformed to CommonJS. + if ( + format === 'module-typescript' || + (format === 'module-commonjs' && ext !== '.ts') + ) { + return nextLoad(url, { ...context, format }); + } + + const transformed = await transformFile(fileURLToPath(url), { + sourceMaps: 'inline', + module: { + type: format === 'module' ? 'es6' : 'commonjs', + ignoreDynamic: true, + + // This helps the Node.js CommonJS compat layer identify named exports. + exportInteropAnnotation: true, + }, + jsc: { + target: 'es2023', + parser: { + syntax: 'typescript', + }, + }, + }); + + return { + ...context, + shortCircuit: true, + source: transformed.code, + format, + responseURL: url, + }; +} diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 4fa5af208c..483b0eccff 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -20,7 +20,8 @@ "main": "src/index.ts", "types": "src/index.ts", "files": [ - "dist" + "dist", + "config" ], "scripts": { "build": "backstage-cli package build", @@ -37,7 +38,10 @@ "@manypkg/get-packages": "^1.1.3", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "^3.0.0", + "chalk": "^4.0.0", + "commander": "^12.0.0", "fs-extra": "^11.2.0", + "pirates": "^4.0.6", "semver": "^7.5.3", "yaml": "^2.0.0", "zod": "^3.25.76" @@ -47,5 +51,13 @@ "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", "@types/yarnpkg__lockfile": "^1.1.4" + }, + "peerDependencies": { + "@swc/core": "^1.15.6" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } } } diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 9d4887ca59..2fac82080b 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -86,6 +86,36 @@ export interface BackstagePackageJson { version: string; } +// @public +export interface CliCommand { + deprecated?: boolean; + description: string; + execute: + | ((context: CliCommandContext) => Promise) + | { + loader: () => Promise<{ + default: (context: CliCommandContext) => Promise; + }>; + }; + experimental?: boolean; + path: string[]; +} + +// @public +export interface CliCommandContext { + args: string[]; + info: { + usage: string; + name: string; + }; +} + +// @public +export interface CliModule { + // (undocumented) + readonly $$type: '@backstage/CliModule'; +} + // @public export type ConcurrentTasksOptions = { concurrencyFactor?: number; @@ -93,6 +123,16 @@ export type ConcurrentTasksOptions = { worker: (item: TItem) => Promise; }; +// @public +export function createCliModule(options: { + packageJson: { + name: string; + }; + init: (registry: { + addCommand: (command: CliCommand) => void; + }) => Promise; +}): CliModule; + // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -187,6 +227,7 @@ export type PackageRole = | 'frontend' | 'backend' | 'cli' + | 'cli-module' | 'web-library' | 'node-library' | 'common-library' @@ -212,6 +253,13 @@ export class PackageRoles { static getRoleInfo(role: string): PackageRoleInfo; } +// @public +export function runCliModule(options: { + module: CliModule; + name: string; + version?: string; +}): Promise; + // @public export function runConcurrentTasks( options: ConcurrentTasksOptions, diff --git a/packages/cli-node/src/cli-module/createCliModule.ts b/packages/cli-node/src/cli-module/createCliModule.ts new file mode 100644 index 0000000000..b2facc3a67 --- /dev/null +++ b/packages/cli-node/src/cli-module/createCliModule.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OpaqueCliModule } from '@internal/cli'; +import { CliCommand, CliModule } from './types'; + +/** + * Creates a new CLI plugin that provides commands to the Backstage CLI. + * + * The `init` callback is invoked immediately at creation time and is used + * to register commands via the provided registry. The commands are then + * made available to the CLI host once the returned promise resolves. + * + * @example + * ``` + * import { createCliModule } from '@backstage/cli-node'; + * import packageJson from '../package.json'; + * + * export default createCliModule({ + * packageJson, + * init: async reg => { + * reg.addCommand({ + * path: ['repo', 'test'], + * description: 'Run tests across the repository', + * execute: { loader: () => import('./commands/test') }, + * }); + * }, + * }); + * ``` + * + * @public + */ +export function createCliModule(options: { + /** The `package.json` contents of the plugin package, used to identify the plugin. */ + packageJson: { name: string }; + /** + * An initialization callback that registers commands with the CLI. + * Called immediately when the plugin is created. + */ + init: (registry: { + /** Registers a new command with the CLI. */ + addCommand: (command: CliCommand) => void; + }) => Promise; +}): CliModule { + if (!options.packageJson.name) { + throw new Error( + 'The packageJson provided to createCliModule must have a name', + ); + } + + const commands: CliCommand[] = []; + const commandsPromise = Promise.resolve() + .then(() => options.init({ addCommand: command => commands.push(command) })) + .then(() => commands); + + return OpaqueCliModule.createInstance('v1', { + packageName: options.packageJson.name, + commands: commandsPromise, + }); +} diff --git a/packages/cli-node/src/cli-module/index.ts b/packages/cli-node/src/cli-module/index.ts new file mode 100644 index 0000000000..56324c3423 --- /dev/null +++ b/packages/cli-node/src/cli-module/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createCliModule } from './createCliModule'; +export { runCliModule } from './runCliModule'; +export type { CliCommand, CliCommandContext, CliModule } from './types'; diff --git a/packages/cli-node/src/cli-module/runCliModule.ts b/packages/cli-node/src/cli-module/runCliModule.ts new file mode 100644 index 0000000000..0dc40e4c33 --- /dev/null +++ b/packages/cli-node/src/cli-module/runCliModule.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + OpaqueCliModule, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, + isCommandNodeHidden, +} from '@internal/cli'; +import type { CommandNode } from '@internal/cli'; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { isError, stringifyError } from '@backstage/errors'; +import type { CliModule, CliCommand } from './types'; + +function buildCommandGraph(commands: ReadonlyArray): CommandNode[] { + const graph: CommandNode[] = []; + + for (const command of commands) { + const { path } = command; + let current = graph; + + for (let i = 0; i < path.length - 1; i++) { + const name = path[i]; + let next = current.find( + n => + OpaqueCommandTreeNode.isType(n) && + OpaqueCommandTreeNode.toInternal(n).name === name, + ); + if (!next) { + next = OpaqueCommandTreeNode.createInstance('v1', { + name, + children: [], + }); + current.push(next); + } + current = OpaqueCommandTreeNode.toInternal(next).children; + } + + current.push( + OpaqueCommandLeafNode.createInstance('v1', { + name: path[path.length - 1], + command, + }), + ); + } + + return graph; +} + +function exitWithError(error: unknown): never { + process.stderr.write(`\n${chalk.red(stringifyError(error))}\n\n`); + process.exit( + isError(error) && 'code' in error && typeof error.code === 'number' + ? error.code + : 1, + ); +} + +function registerCommands( + graph: CommandNode[], + program: Command, + programName: string, +): void { + const queue = graph.map(node => ({ node, argParser: program })); + + while (queue.length) { + const { node, argParser } = queue.shift()!; + + if (OpaqueCommandTreeNode.isType(node)) { + const internal = OpaqueCommandTreeNode.toInternal(node); + const treeParser = argParser + .command(`${internal.name} [command]`, { + hidden: isCommandNodeHidden(node), + }) + .description(internal.name); + + queue.push( + ...internal.children.map(child => ({ + node: child, + argParser: treeParser, + })), + ); + } else { + const internal = OpaqueCommandLeafNode.toInternal(node); + argParser + .command(internal.name, { + hidden: + !!internal.command.deprecated || !!internal.command.experimental, + }) + .description(internal.command.description) + .helpOption(false) + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + const args = program.parseOptions(process.argv); + + const nonProcessArgs = args.operands.slice(2); + const positionalArgs = []; + let index = 0; + for ( + let argIndex = 0; + argIndex < nonProcessArgs.length; + argIndex++ + ) { + if ( + argIndex === index && + internal.command.path[argIndex] === nonProcessArgs[argIndex] + ) { + index += 1; + continue; + } + positionalArgs.push(nonProcessArgs[argIndex]); + } + const context = { + args: [...positionalArgs, ...args.unknown], + info: { + usage: [programName, ...internal.command.path].join(' '), + name: internal.command.path.join(' '), + }, + }; + + if (typeof internal.command.execute === 'function') { + await internal.command.execute(context); + } else { + const mod = await internal.command.execute.loader(); + const fn = + typeof mod.default === 'function' + ? mod.default + : (mod.default as any).default; + await fn(context); + } + process.exit(0); + } catch (error: unknown) { + exitWithError(error); + } + }); + } + } +} + +/** + * Runs a CLI module as a standalone program. + * + * This helper extracts the commands from a {@link CliModule} and exposes + * them as a fully functional CLI with help output and argument parsing. + * It is intended to be called from a module package's `bin` entry point + * so that the module can be executed directly without being wired into + * a larger CLI host. + * + * @example + * ```ts + * #!/usr/bin/env node + * import { runCliModule } from '@backstage/cli-node'; + * import cliModule from './index'; + * + * runCliModule({ + * module: cliModule, + * name: 'backstage-auth', + * version: require('../package.json').version, + * }); + * ``` + * + * @public + */ +export async function runCliModule(options: { + /** The CLI module to run. */ + module: CliModule; + /** The program name shown in help output and usage strings. */ + name: string; + /** The version string shown when `--version` is passed. */ + version?: string; +}): Promise { + const { module: cliModule, name, version } = options; + + if (!OpaqueCliModule.isType(cliModule)) { + throw new Error( + `Invalid CLI module: expected a module created with createCliModule`, + ); + } + + const internal = OpaqueCliModule.toInternal(cliModule); + const commands = await internal.commands; + const graph = buildCommandGraph(commands); + + const program = new Command(); + program.name(name).allowUnknownOption(true).allowExcessArguments(true); + + if (version) { + program.version(version); + } + + registerCommands(graph, program, name); + + program.on('command:*', () => { + console.log(); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); + console.log(); + program.outputHelp(); + process.exit(1); + }); + + process.on('unhandledRejection', rejection => { + exitWithError(rejection); + }); + + await program.parseAsync(process.argv); +} diff --git a/packages/cli-node/src/cli-module/types.ts b/packages/cli-node/src/cli-module/types.ts new file mode 100644 index 0000000000..3db290f89f --- /dev/null +++ b/packages/cli-node/src/cli-module/types.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The context provided to a CLI command at the time of execution. + * + * Contains the parsed arguments and metadata about the command being run. + * + * @public + */ +export interface CliCommandContext { + /** + * The remaining arguments passed to the command after the command path + * has been resolved. This includes both positional arguments and flags. + * + * For example, running `backstage-cli repo test --verbose src/` would + * result in `args` being `['--verbose', 'src/']`. + */ + args: string[]; + /** + * Metadata about the command being executed. + */ + info: { + /** + * The full usage string of the command including the program name, + * for example `"backstage-cli repo test"`. + */ + usage: string; + /** + * The name of the command as defined by its path, + * for example `"repo test"`. + */ + name: string; + }; +} + +/** + * A command definition for a Backstage CLI plugin. + * + * Each command is identified by a `path` that determines its position in + * the command tree. For example, a path of `['repo', 'test']` registers + * the command as `backstage-cli repo test`. + * + * Commands can either provide an `execute` function directly, or use a + * `loader` for deferred loading of the implementation. The loader pattern + * is recommended for commands with heavy dependencies, as it avoids + * loading the implementation until the command is actually invoked. + * + * @public + */ +export interface CliCommand { + /** + * The path segments that define the command's position in the CLI tree. + * For example, `['repo', 'test']` maps to `backstage-cli repo test`. + */ + path: string[]; + /** + * A short description of the command, displayed in help output. + */ + description: string; + /** + * If `true`, the command is deprecated and will be hidden from help output + * but can still be invoked. + */ + deprecated?: boolean; + /** + * If `true`, the command is experimental and will be hidden from help + * output but can still be invoked. + */ + experimental?: boolean; + /** + * The command implementation, either as a direct function or as a loader + * that returns the implementation as a default export. The loader form + * is useful for deferring heavy imports until the command is invoked. + * + * @example + * Direct execution: + * ``` + * execute: async ({ args }) => { ... } + * ``` + * + * @example + * Deferred loading: + * ``` + * execute: { loader: () => import('./my-command') } + * ``` + */ + execute: + | ((context: CliCommandContext) => Promise) + | { + loader: () => Promise<{ + default: (context: CliCommandContext) => Promise; + }>; + }; +} + +/** + * An opaque representation of a Backstage CLI plugin, created + * using {@link createCliModule}. + * + * @public + */ +export interface CliModule { + readonly $$type: '@backstage/CliModule'; +} diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 35074507c4..8831753a82 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -21,6 +21,7 @@ */ export * from './cache'; +export * from './cli-module'; export * from './concurrency'; export * from './git'; export * from './monorepo'; diff --git a/packages/cli-node/src/roles/PackageRoles.ts b/packages/cli-node/src/roles/PackageRoles.ts index 70b62e2479..7761b0d4cc 100644 --- a/packages/cli-node/src/roles/PackageRoles.ts +++ b/packages/cli-node/src/roles/PackageRoles.ts @@ -33,6 +33,11 @@ const packageRoleInfos: PackageRoleInfo[] = [ platform: 'node', output: ['cjs'], }, + { + role: 'cli-module', + platform: 'node', + output: ['types', 'cjs'], + }, { role: 'web-library', platform: 'web', diff --git a/packages/cli-node/src/roles/types.ts b/packages/cli-node/src/roles/types.ts index d6eff04541..d0eaedbd02 100644 --- a/packages/cli-node/src/roles/types.ts +++ b/packages/cli-node/src/roles/types.ts @@ -23,6 +23,7 @@ export type PackageRole = | 'frontend' | 'backend' | 'cli' + | 'cli-module' | 'web-library' | 'node-library' | 'common-library' diff --git a/packages/cli/config/eslint-factory.js b/packages/cli/config/eslint-factory.js index dbb40537ab..4e51803802 100644 --- a/packages/cli/config/eslint-factory.js +++ b/packages/cli/config/eslint-factory.js @@ -269,6 +269,7 @@ function createConfigForRole(dir, role, extraConfig = {}) { }); case 'cli': + case 'cli-module': case 'node-library': case 'backend': case 'backend-plugin': diff --git a/packages/cli/config/getJestEnvironment.js b/packages/cli/config/getJestEnvironment.js index aac2f79021..e6fb66f407 100644 --- a/packages/cli/config/getJestEnvironment.js +++ b/packages/cli/config/getJestEnvironment.js @@ -14,36 +14,14 @@ * limitations under the License. */ -function getJestMajorVersion() { - const jestVersion = require('jest/package.json').version; - const majorVersion = parseInt(jestVersion.split('.')[0], 10); - return majorVersion; -} - -function getJestEnvironment() { - const majorVersion = getJestMajorVersion(); - - if (majorVersion >= 30) { - try { - require.resolve('@jest/environment-jsdom-abstract'); - require.resolve('jsdom'); - } catch { - throw new Error( - 'Jest 30+ requires @jest/environment-jsdom-abstract and jsdom. ' + - 'Please install them as dev dependencies.', - ); - } - return require.resolve('./jest-environment-jsdom'); - } - try { - require.resolve('jest-environment-jsdom'); - } catch { +try { + module.exports = require('@backstage/cli-module-test-jest/config/getJestEnvironment'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { throw new Error( - 'Jest 29 requires jest-environment-jsdom. ' + - 'Please install it as a dev dependency.', + '@backstage/cli-module-test-jest is required to use the jest environment configuration. ' + + 'Please install it as a dependency.', ); } - return require.resolve('jest-environment-jsdom'); + throw e; } - -module.exports = { getJestMajorVersion, getJestEnvironment }; diff --git a/packages/cli/config/jest-environment-jsdom/index.js b/packages/cli/config/jest-environment-jsdom/index.js index 9d8b76eaf5..f9be215d76 100644 --- a/packages/cli/config/jest-environment-jsdom/index.js +++ b/packages/cli/config/jest-environment-jsdom/index.js @@ -14,48 +14,14 @@ * limitations under the License. */ -const JSDOMEnvironment = require('@jest/environment-jsdom-abstract').default; -const jsdom = require('jsdom'); - -/** - * A custom JSDOM environment that extends the abstract base and applies - * fixes for Web API globals that are missing or incorrectly implemented - * in JSDOM. - * - * Based on https://github.com/mswjs/jest-fixed-jsdom - */ -class FixedJSDOMEnvironment extends JSDOMEnvironment { - constructor(config, context) { - super(config, context, jsdom); - - // Fix Web API globals that JSDOM doesn't properly expose - this.global.TextDecoder = TextDecoder; - this.global.TextEncoder = TextEncoder; - this.global.TextDecoderStream = TextDecoderStream; - this.global.TextEncoderStream = TextEncoderStream; - this.global.ReadableStream = ReadableStream; - - this.global.Blob = Blob; - this.global.Headers = Headers; - this.global.FormData = FormData; - this.global.Request = Request; - this.global.Response = Response; - this.global.fetch = fetch; - this.global.AbortController = AbortController; - this.global.AbortSignal = AbortSignal; - this.global.structuredClone = structuredClone; - this.global.URL = URL; - this.global.URLSearchParams = URLSearchParams; - - this.global.BroadcastChannel = BroadcastChannel; - this.global.TransformStream = TransformStream; - this.global.WritableStream = WritableStream; - - // Needed to ensure `e instanceof Error` works as expected with errors thrown from - // any of the native APIs above. Without this, the JSDOM `Error` is what the test - // code will use for comparison with `e`, which fails the instanceof check. - this.global.Error = Error; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jest-environment-jsdom'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest JSDOM environment. ' + + 'Please install it as a dependency.', + ); } + throw e; } - -module.exports = FixedJSDOMEnvironment; diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index c670f0fe8b..e06d0e319e 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -14,408 +14,14 @@ * limitations under the License. */ -const fs = require('fs-extra'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const glob = require('node:util').promisify(require('glob')); -const { version } = require('../package.json'); -const paths = require('@backstage/cli-common').findPaths(process.cwd()); -const { - getJestEnvironment, - getJestMajorVersion, -} = require('./getJestEnvironment'); - -const SRC_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mts', 'cts', 'mjs', 'cjs']; - -const FRONTEND_ROLES = [ - 'frontend', - 'web-library', - 'common-library', - 'frontend-plugin', - 'frontend-plugin-module', -]; - -const NODE_ROLES = [ - 'backend', - 'cli', - 'node-library', - 'backend-plugin', - 'backend-plugin-module', -]; - -const envOptions = { - oldTests: Boolean(process.env.BACKSTAGE_OLD_TESTS), -}; - try { - require.resolve('react-dom/client', { - paths: [paths.targetRoot], - }); - process.env.HAS_REACT_DOM_CLIENT = true; -} catch { - /* ignored */ -} - -/** - * A list of config keys that are valid for project-level config. - * Jest will complain if we forward any other root configuration to the projects. - * - * @type {Array} - */ -const projectConfigKeys = [ - 'automock', - 'cache', - 'cacheDirectory', - 'clearMocks', - 'collectCoverageFrom', - 'coverageDirectory', - 'coveragePathIgnorePatterns', - 'cwd', - 'dependencyExtractor', - 'detectLeaks', - 'detectOpenHandles', - 'displayName', - 'errorOnDeprecated', - 'extensionsToTreatAsEsm', - 'fakeTimers', - 'filter', - 'forceCoverageMatch', - 'globalSetup', - 'globalTeardown', - 'globals', - 'haste', - 'id', - 'injectGlobals', - 'moduleDirectories', - 'moduleFileExtensions', - 'moduleNameMapper', - 'modulePathIgnorePatterns', - 'modulePaths', - 'openHandlesTimeout', - 'preset', - 'prettierPath', - 'resetMocks', - 'resetModules', - 'resolver', - 'restoreMocks', - 'rootDir', - 'roots', - 'runner', - 'runtime', - 'sandboxInjectedGlobals', - 'setupFiles', - 'setupFilesAfterEnv', - 'skipFilter', - 'skipNodeResolution', - 'slowTestThreshold', - 'snapshotResolver', - 'snapshotSerializers', - 'snapshotFormat', - 'testEnvironment', - 'testEnvironmentOptions', - 'testMatch', - 'testLocationInResults', - 'testPathIgnorePatterns', - 'testRegex', - 'testRunner', - 'transform', - 'transformIgnorePatterns', - 'watchPathIgnorePatterns', - 'unmockedModulePathPatterns', - 'workerIdleMemoryLimit', -]; - -const transformIgnorePattern = [ - '@material-ui', - 'ajv', - 'core-js', - 'jest-.*', - 'jsdom', - 'knex', - 'react', - 'react-dom', - 'highlight\\.js', - 'prismjs', - 'json-schema', - 'react-use/lib', - 'typescript', -].join('|'); - -// Provides additional config that's based on the role of the target package -function getRoleConfig(role, pkgJson) { - // Only Node.js package roles support native ESM modules, frontend and common - // packages are always transpiled to CommonJS. - const moduleOpts = NODE_ROLES.includes(role) - ? { - module: { - ignoreDynamic: true, - exportInteropAnnotation: true, - }, - } - : undefined; - - const transform = { - '\\.(mjs|cjs|js)$': [ - require.resolve('./jestSwcTransform'), - { - ...moduleOpts, - jsc: { - parser: { - syntax: 'ecmascript', - }, - }, - }, - ], - '\\.jsx$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'ecmascript', - jsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - }, - }, - ], - '\\.(mts|cts|ts)$': [ - require.resolve('./jestSwcTransform'), - { - ...moduleOpts, - jsc: { - parser: { - syntax: 'typescript', - }, - }, - }, - ], - '\\.tsx$': [ - require.resolve('./jestSwcTransform'), - { - jsc: { - parser: { - syntax: 'typescript', - tsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - }, - }, - ], - '\\.(bmp|gif|jpg|jpeg|png|ico|webp|frag|xml|svg|eot|woff|woff2|ttf)$': - require.resolve('./jestFileTransform.js'), - '\\.(yaml)$': require.resolve('./jestYamlTransform'), - }; - if (FRONTEND_ROLES.includes(role)) { - return { - testEnvironment: getJestEnvironment(), - // The caching module loader is only used to speed up frontend tests, - // as it breaks real dynamic imports of ESM modules. - runtime: envOptions.oldTests - ? undefined - : require.resolve('./jestCachingModuleLoader'), - transform, - }; - } - return { - testEnvironment: require.resolve('jest-environment-node'), - moduleFileExtensions: [...SRC_EXTS, 'json', 'node'], - // Jest doesn't let us dynamically detect type=module per transformed file, - // so we have to assume that if the entry point is ESM, all TS files are - // ESM. - // - // This means you can't switch a package to type=module until all of its - // monorepo dependencies are also type=module or does not contain any .ts - // files. - extensionsToTreatAsEsm: - pkgJson.type === 'module' ? ['.ts', '.mts'] : ['.mts'], - transform, - }; -} - -async function getProjectConfig(targetPath, extraConfig, extraOptions) { - const configJsPath = path.resolve(targetPath, 'jest.config.js'); - const configTsPath = path.resolve(targetPath, 'jest.config.ts'); - // If the package has it's own jest config, we use that instead. - if (await fs.pathExists(configJsPath)) { - return require(configJsPath); - } else if (await fs.pathExists(configTsPath)) { - return require(configTsPath); - } - - // Jest config can be defined both in the root package.json and within each package. The root config - // gets forwarded to us through the `extraConfig` parameter, while the package config is read here. - // If they happen to be the same the keys will simply override each other. - // The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined. - const pkgJson = await fs.readJson(path.resolve(targetPath, 'package.json')); - - const options = { - ...extraConfig, - rootDir: path.resolve(targetPath, 'src'), - moduleNameMapper: { - '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), - }, - - // A bit more opinionated - testMatch: [`**/*.test.{${SRC_EXTS.join(',')}}`], - - transformIgnorePatterns: [`/node_modules/(?:${transformIgnorePattern})/`], - ...getRoleConfig(pkgJson.backstage?.role, pkgJson), - }; - - options.setupFilesAfterEnv = options.setupFilesAfterEnv || []; - - if ( - extraOptions.rejectFrontendNetworkRequests && - FRONTEND_ROLES.includes(pkgJson.backstage?.role) - ) { - // By adding this first we ensure that it's possible to for example override - // fetch with a mock in a custom setup file - options.setupFilesAfterEnv.unshift( - require.resolve('./jestRejectNetworkRequests.js'), + module.exports = require('@backstage/cli-module-test-jest/config/jest'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use this jest configuration. ' + + 'Please install it as a dependency.', ); } - - if ( - options.testEnvironment === getJestEnvironment() && - getJestMajorVersion() < 30 // Only needed when not running the custom env for Jest 30+ - ) { - // FIXME https://github.com/jsdom/jsdom/issues/1724 - options.setupFilesAfterEnv.unshift(require.resolve('cross-fetch/polyfill')); - } - - // Use src/setupTests.* as the default location for configuring test env - for (const ext of SRC_EXTS) { - if (fs.existsSync(path.resolve(targetPath, `src/setupTests.${ext}`))) { - options.setupFilesAfterEnv.push(`/setupTests.${ext}`); - break; - } - } - - const config = Object.assign(options, pkgJson.jest); - - // The config id is a cache key that lets us share the jest cache across projects. - // If no explicit id was configured, generated one based on the configuration. - if (!config.id) { - const configHash = crypto - .createHash('sha256') - .update(version) - .update(Buffer.alloc(1)) - .update(JSON.stringify(config.transform).replaceAll(paths.targetRoot, '')) - .digest('hex'); - config.id = `backstage_cli_${configHash}`; - } - - return config; + throw e; } - -// This loads the root jest config, which in turn will either refer to a single -// configuration for the current package, or a collection of configurations for -// the target workspace packages -async function getRootConfig() { - const rootPkgJson = await fs.readJson( - paths.resolveTargetRoot('package.json'), - ); - - const baseCoverageConfig = { - coverageDirectory: paths.resolveTarget('coverage'), - coverageProvider: envOptions.oldTests ? 'v8' : 'babel', - collectCoverageFrom: ['**/*.{js,jsx,ts,tsx,mjs,cjs}', '!**/*.d.ts'], - }; - - const { rejectFrontendNetworkRequests, ...rootOptions } = - rootPkgJson.jest ?? {}; - const extraRootOptions = { - rejectFrontendNetworkRequests, - }; - - const ws = rootPkgJson.workspaces; - const workspacePatterns = Array.isArray(ws) ? ws : ws?.packages; - - // Check if we're running within a specific monorepo package. In that case just get the single project config. - if (!workspacePatterns || paths.targetRoot !== paths.targetDir) { - return getProjectConfig( - paths.targetDir, - { - ...baseCoverageConfig, - ...rootOptions, - }, - extraRootOptions, - ); - } - - const globalRootConfig = { ...baseCoverageConfig }; - const globalProjectConfig = {}; - - for (const [key, value] of Object.entries(rootOptions)) { - if (projectConfigKeys.includes(key)) { - globalProjectConfig[key] = value; - } else { - globalRootConfig[key] = value; - } - } - - // If the target package is a workspace root, we find all packages in the - // workspace and load those in as separate jest projects instead. - const projectPaths = await Promise.all( - workspacePatterns.map(pattern => - glob(path.join(paths.targetRoot, pattern)), - ), - ).then(_ => _.flat()); - - let projects = await Promise.all( - projectPaths.flat().map(async projectPath => { - const packagePath = path.resolve(projectPath, 'package.json'); - if (!(await fs.pathExists(packagePath))) { - return undefined; - } - - // We check for the presence of "backstage-cli test" in the package test - // script to determine whether a given package should be tested - const packageData = await fs.readJson(packagePath); - const testScript = packageData.scripts && packageData.scripts.test; - const isSupportedTestScript = - testScript?.includes('backstage-cli test') || - testScript?.includes('backstage-cli package test'); - if (testScript && isSupportedTestScript) { - return await getProjectConfig( - projectPath, - { - ...globalProjectConfig, - displayName: packageData.name, - }, - extraRootOptions, - ); - } - - return undefined; - }), - ).then(cs => cs.filter(Boolean)); - - const cache = global.__backstageCli_jestSuccessCache; - if (cache) { - projects = await cache.filterConfigs(projects, globalRootConfig); - } - const watchProjectFilter = global.__backstageCli_watchProjectFilter; - if (watchProjectFilter) { - projects = await watchProjectFilter.filter(projects); - } - - return { - rootDir: paths.targetRoot, - projects, - testResultsProcessor: cache - ? require.resolve('./jestCacheResultProcessor.cjs') - : undefined, - ...globalRootConfig, - }; -} - -module.exports = getRootConfig(); diff --git a/packages/cli/config/jestCacheResultProcessor.cjs b/packages/cli/config/jestCacheResultProcessor.cjs index 4af401d8b5..58217428cc 100644 --- a/packages/cli/config/jestCacheResultProcessor.cjs +++ b/packages/cli/config/jestCacheResultProcessor.cjs @@ -14,10 +14,14 @@ * limitations under the License. */ -module.exports = async results => { - const cache = global.__backstageCli_jestSuccessCache; - if (cache) { - await cache.reportResults(results); +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestCacheResultProcessor.cjs'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest cache result processor. ' + + 'Please install it as a dependency.', + ); } - return results; -}; + throw e; +} diff --git a/packages/cli/config/jestCachingModuleLoader.js b/packages/cli/config/jestCachingModuleLoader.js index 5652ff67e7..2b6da11007 100644 --- a/packages/cli/config/jestCachingModuleLoader.js +++ b/packages/cli/config/jestCachingModuleLoader.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * Copyright 2023 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,22 +14,14 @@ * limitations under the License. */ -// 'jest-runtime' is included with jest and should be kept in sync with the installed jest version -// eslint-disable-next-line @backstage/no-undeclared-imports -const { default: JestRuntime } = require('jest-runtime'); - -module.exports = class CachingJestRuntime extends JestRuntime { - constructor(config, ...restArgs) { - super(config, ...restArgs); - this.allowLoadAsEsm = config.extensionsToTreatAsEsm.includes('.mts'); +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestCachingModuleLoader'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest caching module loader. ' + + 'Please install it as a dependency.', + ); } - - // Unfortunately we need to use this unstable API to make sure that .js files - // are only loaded as modules where ESM is supported, i.e. Node.js packages. - unstable_shouldLoadAsEsm(path, ...restArgs) { - if (!this.allowLoadAsEsm) { - return false; - } - return super.unstable_shouldLoadAsEsm(path, ...restArgs); - } -}; + throw e; +} diff --git a/packages/cli/config/jestFileTransform.js b/packages/cli/config/jestFileTransform.js index ad1c37a4f5..00e1c22815 100644 --- a/packages/cli/config/jestFileTransform.js +++ b/packages/cli/config/jestFileTransform.js @@ -14,31 +14,14 @@ * limitations under the License. */ -const path = require('node:path'); - -module.exports = { - process(src, filename) { - const assetFilename = JSON.stringify(path.basename(filename)); - - if (filename.match(/\.icon\.svg$/)) { - return { - code: `const React = require('react'); - const SvgIcon = require('@material-ui/core/SvgIcon').default; - module.exports = { - __esModule: true, - default: props => React.createElement(SvgIcon, props, { - $$typeof: Symbol.for('react.element'), - type: 'svg', - ref: ref, - key: null, - props: Object.assign({}, props, { - children: ${assetFilename} - }) - }) - };`, - }; - } - - return { code: `module.exports = ${assetFilename};` }; - }, -}; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestFileTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest file transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; +} diff --git a/packages/cli/config/jestRejectNetworkRequests.js b/packages/cli/config/jestRejectNetworkRequests.js index f6f189a60c..c975257b8c 100644 --- a/packages/cli/config/jestRejectNetworkRequests.js +++ b/packages/cli/config/jestRejectNetworkRequests.js @@ -14,57 +14,14 @@ * limitations under the License. */ -const http = require('node:http'); -const https = require('node:https'); - -const errorMessage = 'Network requests are not allowed in tests'; - -const origHttpAgent = http.globalAgent; -const origHttpsAgent = https.globalAgent; -const origFetch = global.fetch; -const origXMLHttpRequest = global.XMLHttpRequest; - -http.globalAgent = new http.Agent({ - lookup() { - throw new Error(errorMessage); - }, -}); - -https.globalAgent = new https.Agent({ - lookup() { - throw new Error(errorMessage); - }, -}); - -const BLOCKING_FETCH_SYMBOL = Symbol.for( - 'backstage.jestRejectNetworkRequests.blockingFetch', -); - -if (global.fetch) { - const blockingFetch = async (input, init) => { - // If global.fetch still has our marker, block the request - if (global.fetch[BLOCKING_FETCH_SYMBOL]) { - throw new Error(errorMessage); - } - // MSW (or something else) wrapped us - pass through - return origFetch(input, init); - }; - blockingFetch[BLOCKING_FETCH_SYMBOL] = true; - global.fetch = blockingFetch; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestRejectNetworkRequests'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest network request rejection. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -if (global.XMLHttpRequest) { - global.XMLHttpRequest = class { - constructor() { - throw new Error(errorMessage); - } - }; -} - -// Reset overrides after each suite to make sure we don't pollute the test environment -afterAll(() => { - http.globalAgent = origHttpAgent; - https.globalAgent = origHttpsAgent; - global.fetch = origFetch; - global.XMLHttpRequest = origXMLHttpRequest; -}); diff --git a/packages/cli/config/jestSucraseTransform.js b/packages/cli/config/jestSucraseTransform.js index 621b9ee297..fe20351c29 100644 --- a/packages/cli/config/jestSucraseTransform.js +++ b/packages/cli/config/jestSucraseTransform.js @@ -14,74 +14,14 @@ * limitations under the License. */ -const { createHash } = require('node:crypto'); -const { transform } = require('sucrase'); -const sucrasePkg = require('sucrase/package.json'); - -const ESM_REGEX = /\b(?:import|export)\b/; - -function createTransformer(config) { - const process = (source, filePath) => { - let transforms; - - if (filePath.endsWith('.esm.js')) { - transforms = ['imports']; - } else if (filePath.endsWith('.js')) { - // This is a very rough filter to avoid transforming things that we quickly - // can be sure are definitely not ESM modules. - if (ESM_REGEX.test(source)) { - transforms = ['imports', 'jsx']; // JSX within .js is currently allowed - } - } else if (filePath.endsWith('.jsx')) { - transforms = ['jsx', 'imports']; - } else if (filePath.endsWith('.ts')) { - transforms = ['typescript', 'imports']; - } else if (filePath.endsWith('.tsx')) { - transforms = ['typescript', 'jsx', 'imports']; - } - - // Only apply the jest transform to the test files themselves - if (transforms && filePath.includes('.test.')) { - transforms.push('jest'); - } - - if (transforms) { - const { code, sourceMap: map } = transform(source, { - transforms, - filePath, - disableESTransforms: true, - sourceMapOptions: { - compiledFilename: filePath, - }, - }); - if (config.enableSourceMaps) { - const b64 = Buffer.from(JSON.stringify(map), 'utf8').toString('base64'); - const suffix = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}`; - // Include both inline and object source maps, as inline source maps are - // needed for support of some editor integrations. - return { code: `${code}\n${suffix}`, map }; - } - // We only return the `map` result if source maps are enabled, as they - // have a negative impact on the coverage accuracy. - return { code }; - } - - return { code: source }; - }; - - const getCacheKey = sourceText => { - return createHash('sha256') - .update(sourceText) - .update(Buffer.alloc(1)) - .update(sucrasePkg.version) - .update(Buffer.alloc(1)) - .update(JSON.stringify(config)) - .update(Buffer.alloc(1)) - .update('1') // increment whenever the transform logic in this file changes - .digest('hex'); - }; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestSucraseTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest Sucrase transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/jestSwcTransform.js b/packages/cli/config/jestSwcTransform.js index 4183349554..4aa6776c22 100644 --- a/packages/cli/config/jestSwcTransform.js +++ b/packages/cli/config/jestSwcTransform.js @@ -13,32 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -const { createTransformer: createSwcTransformer } = require('@swc/jest'); -const ESM_REGEX = /\b(?:import|export)\b/; - -function createTransformer(config) { - const swcTransformer = createSwcTransformer({ - inputSourceMap: false, - ...config, - }); - const process = (source, filePath, jestOptions) => { - // Skip transformation of .js files without ESM syntax, we never transform from CJS to ESM - if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { - return { code: source }; - } - - // Skip transformation of .mjs files, they should only be used if ESM support is available - if (jestOptions.supportsStaticESM && filePath.endsWith('.mjs')) { - return { code: source }; - } - - return swcTransformer.process(source, filePath, jestOptions); - }; - - const getCacheKey = swcTransformer.getCacheKey; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestSwcTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest SWC transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/jestYamlTransform.js b/packages/cli/config/jestYamlTransform.js index 7a05274add..85b889498f 100644 --- a/packages/cli/config/jestYamlTransform.js +++ b/packages/cli/config/jestYamlTransform.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * Copyright 2020 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,27 +14,14 @@ * limitations under the License. */ -const yaml = require('yaml'); -const crypto = require('node:crypto'); - -function createTransformer(config) { - const process = source => { - const json = JSON.stringify(yaml.parse(source), null, 2); - return { code: `module.exports = ${json}`, map: null }; - }; - - const getCacheKey = sourceText => { - return crypto - .createHash('sha256') - .update(sourceText) - .update(Buffer.alloc(1)) - .update(JSON.stringify(config)) - .update(Buffer.alloc(1)) - .update('1') // increment whenever the transform logic in this file changes - .digest('hex'); - }; - - return { process, getCacheKey }; +try { + module.exports = require('@backstage/cli-module-test-jest/config/jestYamlTransform'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-test-jest is required to use the jest YAML transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -module.exports = { createTransformer }; diff --git a/packages/cli/config/nodeTransform.cjs b/packages/cli/config/nodeTransform.cjs index 15bcdba9bc..c6866b2964 100644 --- a/packages/cli/config/nodeTransform.cjs +++ b/packages/cli/config/nodeTransform.cjs @@ -14,74 +14,14 @@ * limitations under the License. */ -const { pathToFileURL } = require('node:url'); -const { transformSync } = require('@swc/core'); -const { addHook } = require('pirates'); -const { Module } = require('node:module'); - -// This hooks into module resolution and overrides imports of packages that -// exist in the linked workspace to instead be resolved from the linked workspace. -if (process.env.BACKSTAGE_CLI_LINKED_WORKSPACE) { - const { join: joinPath } = require('node:path'); - const { getPackagesSync } = require('@manypkg/get-packages'); - const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( - process.env.BACKSTAGE_CLI_LINKED_WORKSPACE, - ); - - // Matches all packages in the linked workspaces, as well as sub-path exports from them - const replacementRegex = new RegExp( - `^(?:${linkedPackages - .map(pkg => pkg.packageJson.name) - .join('|')})(?:/.*)?$`, - ); - - const origLoad = Module._load; - Module._load = function requireHook(request, parent) { - if (!replacementRegex.test(request)) { - return origLoad.call(this, request, parent); - } - - // The package import that we're overriding will always existing in the root - // node_modules of the linked workspace, so it's enough to override the - // parent paths with that single entry - return origLoad.call(this, request, { - ...parent, - paths: [joinPath(linkedRoot.dir, 'node_modules')], - }); - }; +try { + require('@backstage/cli-node/config/nodeTransform.cjs'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-node is required to use the node transform. ' + + 'Please install it as a dependency.', + ); + } + throw e; } - -addHook( - (code, filename) => { - const transformed = transformSync(code, { - filename, - sourceMaps: 'inline', - module: { - type: 'commonjs', - ignoreDynamic: true, - }, - jsc: { - target: 'es2023', - parser: { - syntax: 'typescript', - }, - }, - }); - process.send?.({ type: 'watch', path: filename }); - return transformed.code; - }, - { extensions: ['.ts', '.cts'], ignoreNodeModules: true }, -); - -addHook( - (code, filename) => { - process.send?.({ type: 'watch', path: filename }); - return code; - }, - { extensions: ['.js', '.cjs'], ignoreNodeModules: true }, -); - -// Register module hooks, used by "type": "module" in package.json, .mjs and -// .mts files, as well as dynamic import(...)s, although dynamic imports will be -// handled be the CommonJS hooks in this file if what it points to is CommonJS. -Module.register('./nodeTransformHooks.mjs', pathToFileURL(__filename)); diff --git a/packages/cli/config/nodeTransformHooks.mjs b/packages/cli/config/nodeTransformHooks.mjs index 592867e57a..274e24ed39 100644 --- a/packages/cli/config/nodeTransformHooks.mjs +++ b/packages/cli/config/nodeTransformHooks.mjs @@ -14,281 +14,7 @@ * limitations under the License. */ -import { dirname, extname, resolve as resolvePath } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { transformFile } from '@swc/core'; -import { isBuiltin } from 'node:module'; -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; - -// @ts-check - -// No explicit file extension, no type in package.json -const DEFAULT_MODULE_FORMAT = 'commonjs'; - -// Source file extensions to look for when using bundle resolution strategy -const SRC_EXTS = ['.ts', '.js']; -const TS_EXTS = ['.ts', '.mts', '.cts']; -const moduleTypeTable = { - '.mjs': 'module', - '.mts': 'module', - '.cjs': 'commonjs', - '.cts': 'commonjs', - '.ts': undefined, - '.js': undefined, -}; - -/** @type {import('module').ResolveHook} */ -export async function resolve(specifier, context, nextResolve) { - // Built-in modules are handled by the default resolver - if (isBuiltin(specifier)) { - return nextResolve(specifier, context); - } - - const ext = extname(specifier); - - // Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below. - if (ext === '.json' && !context.importAttributes?.type) { - const jsonResult = await nextResolve(specifier, context); - return { - ...jsonResult, - format: 'commonjs', - importAttributes: { type: 'json' }, - }; - } - - // Anything else with an explicit extension is handled by the default - // resolver, except that we help determine the module type where needed. - if (ext !== '') { - return withDetectedModuleType(await nextResolve(specifier, context)); - } - - // Other external modules are handled by the default resolver, but again we - // help determine the module type where needed. - if (!specifier.startsWith('.')) { - return withDetectedModuleType(await nextResolve(specifier, context)); - } - - // The rest of this function handles the case of resolving imports that do not - // specify any extension and might point to a directory with an `index.*` - // file. We resolve those using the same logic as most JS bundlers would, with - // the addition of checking if there's an explicit module format listed in the - // closest `package.json` file. - // - // We use a bundle resolution strategy in order to keep code consistent across - // Backstage codebases that contains code both for Web and Node.js, and to - // support packages with common code that can be used in both environments. - try { - // This is expected to throw, but in the event that this module specifier is - // supported we prefer to use the default resolver. - return await nextResolve(specifier, context); - } catch (error) { - if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') { - const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`; - const resolved = await resolveWithoutExt(spec, context, nextResolve); - if (resolved) { - return withDetectedModuleType(resolved); - } - } else if (error.code === 'ERR_MODULE_NOT_FOUND') { - const resolved = await resolveWithoutExt(specifier, context, nextResolve); - if (resolved) { - return withDetectedModuleType(resolved); - } - } - - // Unexpected error or no resolution found - throw error; - } -} - -/** - * Populates the `format` field in the resolved object based on the closest `package.json` file. - * - * @param {import('module').ResolveFnOutput} resolved - * @returns {Promise} - */ -async function withDetectedModuleType(resolved) { - // Already has an explicit format - if (resolved.format) { - return resolved; - } - // Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default. - if (resolved.format === null) { - return { ...resolved, format: DEFAULT_MODULE_FORMAT }; - } - - const ext = extname(resolved.url); - - const explicitFormat = moduleTypeTable[ext]; - if (explicitFormat) { - return { - ...resolved, - format: explicitFormat, - }; - } - - // Under normal circumstances .js files should reliably have a format and so - // we should only reach this point for .ts files. However, if additional - // custom loaders are being used the format may not be detected for .js files - // either. As such we don't restrict the file format at this point. - - // TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring - const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url)); - if (!packageJsonPath) { - return resolved; - } - - const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); - return { - ...resolved, - format: packageJson.type ?? DEFAULT_MODULE_FORMAT, - }; -} - -/** - * Find the closest package.json file from the given path. - * - * TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable. - * @param {string} startPath - * @returns {Promise} - */ -async function findPackageJSON(startPath) { - let path = startPath; - - // Some confidence check to avoid infinite loop - for (let i = 0; i < 1000; i++) { - const packagePath = resolvePath(path, 'package.json'); - if (existsSync(packagePath)) { - return packagePath; - } - - const newPath = dirname(path); - if (newPath === path) { - return undefined; - } - path = newPath; - } - - throw new Error( - `Iteration limit reached when searching for package.json at ${startPath}`, - ); -} - -/** @type {import('module').ResolveHook} */ -async function resolveWithoutExt(specifier, context, nextResolve) { - for (const tryExt of SRC_EXTS) { - try { - const resolved = await nextResolve(specifier + tryExt, { - ...context, - format: 'commonjs', - }); - return { - ...resolved, - format: moduleTypeTable[tryExt] ?? resolved.format, - }; - } catch { - /* ignore */ - } - } - return undefined; -} - -/** @type {import('module').LoadHook} */ -export async function load(url, context, nextLoad) { - // Non-file URLs are handled by the default loader - if (!url.startsWith('file://')) { - return nextLoad(url, context); - } - - // JSON files loaded as CommonJS are handled by this custom loader, because - // the default one doesn't work. For JSON loading to work we'd need the - // synchronous hooks that aren't supported yet, or avoid using the CommonJS - // compatibility. - if ( - context.format === 'commonjs' && - context.importAttributes?.type === 'json' - ) { - try { - // TODO(Rugvip): Make sure this is valid JSON - const content = await readFile(fileURLToPath(url), 'utf8'); - return { - source: `module.exports = (${content})`, - format: 'commonjs', - shortCircuit: true, - }; - } catch { - // Let the default loader generate the error - return nextLoad(url, context); - } - } - - const ext = extname(url); - - // Non-TS files are handled by the default loader - if (!TS_EXTS.includes(ext)) { - return nextLoad(url, context); - } - - const format = context.format ?? DEFAULT_MODULE_FORMAT; - - // We have two choices at this point, we can either transform CommonJS files - // and return the transformed source code, or let the default loader handle - // them. If we transform them ourselves we will enter CommonJS compatibility - // mode in the new module system in Node.js, this effectively means all - // CommonJS loaded via `require` calls from this point will all be treated as - // if it was loaded via `import` calls from modules. - // - // The CommonJS compatibility layer will try to identify named exports and - // make them available directly, which is convenient as it avoids things like - // `import(...).then(m => m.default.foo)`, allowing you to instead write - // `import(...).then(m => m.foo)`. The compatibility layer doesn't always work - // all that well though, and can lead to module loading issues in many cases, - // especially for older code. - - // This `if` block opts-out of using CommonJS compatibility mode by default, - // and instead leaves it to our existing loader to transform CommonJS. We do - // however use compatibility mode for the more explicit .cts file extension, - // allows for a way to opt-in to the new behavior. - // - // TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead - if (format === 'commonjs' && ext !== '.cts') { - return nextLoad(url, { ...context, format }); - } - - // If the Node.js version we're running supports TypeScript, i.e. type - // stripping, we hand over to the default loader. This is done for all cases - // except if we're loading a .ts file that's been resolved to CommonJS format. - // This is because these files aren't actually CommonJS in the Backstage build - // system, and need to be transformed to CommonJS. - if ( - format === 'module-typescript' || - (format === 'module-commonjs' && ext !== '.ts') - ) { - return nextLoad(url, { ...context, format }); - } - - const transformed = await transformFile(fileURLToPath(url), { - sourceMaps: 'inline', - module: { - type: format === 'module' ? 'es6' : 'commonjs', - ignoreDynamic: true, - - // This helps the Node.js CommonJS compat layer identify named exports. - exportInteropAnnotation: true, - }, - jsc: { - target: 'es2023', - parser: { - syntax: 'typescript', - }, - }, - }); - - return { - ...context, - shortCircuit: true, - source: transformed.code, - format, - responseURL: url, - }; -} +export { + resolve, + load, +} from '@backstage/cli-node/config/nodeTransformHooks.mjs'; diff --git a/packages/cli/config/webpack-public-path.js b/packages/cli/config/webpack-public-path.js index 3e14e96eb9..d1c36222c9 100644 --- a/packages/cli/config/webpack-public-path.js +++ b/packages/cli/config/webpack-public-path.js @@ -14,18 +14,14 @@ * limitations under the License. */ -// This script is used to pick up and set the public path of the Webpack bundle -// at runtime. The meta tag is injected by the app build, but only present in -// the `index.html.tmpl` file. The runtime value of the meta tag is populated by -// the app backend, when it templates the final `index.html` file. -// -// This is needed for additional chunks to use the correct public path, and it -// is not possible to set the `__webpack_public_path__` variable outside of the -// build itself. The Webpack output also does not read any tags or -// similar, this seems to be the only way to dynamically configure the public -// path at runtime. -const el = document.querySelector('meta[name="backstage-public-path"]'); -const path = el?.getAttribute('content'); -if (path) { - __webpack_public_path__ = path; +try { + require('@backstage/cli-module-build/config/webpack-public-path'); +} catch (e) { + if (e.code === 'MODULE_NOT_FOUND') { + throw new Error( + '@backstage/cli-module-build is required to use the webpack public path configuration. ' + + 'Please install it as a dependency.', + ); + } + throw e; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e03c60d0f..4ba287a9ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,50 +47,27 @@ ] }, "dependencies": { - "@backstage/catalog-model": "workspace:^", "@backstage/cli-common": "workspace:^", + "@backstage/cli-defaults": "workspace:^", + "@backstage/cli-module-build": "workspace:^", + "@backstage/cli-module-test-jest": "workspace:^", "@backstage/cli-node": "workspace:^", - "@backstage/config": "workspace:^", - "@backstage/config-loader": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/eslint-plugin": "workspace:^", - "@backstage/integration": "workspace:^", - "@backstage/module-federation-common": "workspace:^", - "@backstage/release-manifests": "workspace:^", - "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", - "@module-federation/enhanced": "^0.21.6", - "@octokit/request": "^8.0.0", - "@rollup/plugin-commonjs": "^26.0.0", - "@rollup/plugin-json": "^6.0.0", - "@rollup/plugin-node-resolve": "^15.0.0", - "@rollup/plugin-yaml": "^4.0.0", - "@rspack/core": "^1.4.11", - "@rspack/dev-server": "^1.1.4", - "@rspack/plugin-react-refresh": "^1.4.3", "@spotify/eslint-config-base": "^15.0.0", "@spotify/eslint-config-react": "^15.0.0", "@spotify/eslint-config-typescript": "^15.0.0", "@swc/core": "^1.15.6", - "@swc/helpers": "^0.5.17", "@swc/jest": "^0.2.39", "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.16.0", - "bfj": "^9.0.2", - "buffer": "^6.0.3", "chalk": "^4.0.0", - "chokidar": "^3.3.1", - "cleye": "^2.3.0", "commander": "^14.0.3", "cross-fetch": "^4.0.0", - "cross-spawn": "^7.0.3", - "css-loader": "^6.5.1", - "ctrlc-windows": "^2.1.0", - "esbuild": "^0.27.0", "eslint": "^8.6.0", "eslint-config-prettier": "^9.0.0", - "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "^28.9.0", @@ -98,55 +75,13 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unused-imports": "^4.1.4", - "eslint-rspack-plugin": "^4.2.1", - "express": "^4.22.0", "fs-extra": "^11.2.0", - "git-url-parse": "^15.0.0", "glob": "^7.1.7", - "global-agent": "^3.0.0", - "globby": "^11.1.0", - "handlebars": "^4.7.3", - "html-webpack-plugin": "^5.6.3", - "inquirer": "^8.2.0", "jest-css-modules": "^2.1.0", - "json-schema": "^0.4.0", - "lodash": "^4.17.21", - "minimatch": "^10.2.1", - "node-stdlib-browser": "^1.3.1", - "npm-packlist": "^5.0.0", - "ora": "^5.3.0", - "p-queue": "^6.6.2", "pirates": "^4.0.6", "postcss": "^8.1.0", - "postcss-import": "^16.1.0", - "process": "^0.11.10", - "proper-lockfile": "^4.1.2", - "raw-loader": "^4.0.2", - "react-dev-utils": "^12.0.0-next.60", - "react-refresh": "^0.18.0", - "recursive-readdir": "^2.2.2", - "replace-in-file": "^7.1.0", - "rollup": "^4.27.3", - "rollup-plugin-dts": "^6.1.0", - "rollup-plugin-esbuild": "^6.1.1", - "rollup-plugin-postcss": "^4.0.0", - "rollup-pluginutils": "^2.8.2", - "semver": "^7.5.3", - "shell-quote": "^1.8.1", - "style-loader": "^3.3.1", "sucrase": "^3.20.2", - "swc-loader": "^0.2.3", - "tar": "^7.5.6", - "ts-checker-rspack-plugin": "^1.1.5", - "ts-morph": "^24.0.0", - "undici": "^7.2.3", - "util": "^0.12.3", - "yaml": "^2.0.0", - "yargs": "^16.2.0", - "yml-loader": "^2.1.0", - "yn": "^4.0.0", - "zod": "^3.25.76", - "zod-validation-error": "^4.0.2" + "yaml": "^2.0.0" }, "devDependencies": { "@backstage/backend-plugin-api": "workspace:^", @@ -166,91 +101,28 @@ "@backstage/test-utils": "workspace:^", "@backstage/theme": "workspace:^", "@jest/environment-jsdom-abstract": "^30.0.0", - "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", - "@types/cross-spawn": "^6.0.2", - "@types/ejs": "^3.1.3", - "@types/express": "^4.17.6", "@types/fs-extra": "^11.0.0", - "@types/http-proxy": "^1.17.4", - "@types/inquirer": "^8.1.3", "@types/jest": "^30.0.0", "@types/node": "^22.13.14", - "@types/npm-packlist": "^3.0.0", - "@types/proper-lockfile": "^4", - "@types/recursive-readdir": "^2.2.0", - "@types/rollup-plugin-peer-deps-external": "^2.2.0", - "@types/rollup-plugin-postcss": "^3.1.4", - "@types/shell-quote": "^1.7.5", - "@types/svgo": "^2.6.2", - "@types/terser-webpack-plugin": "^5.0.4", - "@types/webpack-sources": "^3.2.3", - "del": "^8.0.0", - "esbuild-loader": "^4.0.0", - "eslint-webpack-plugin": "^4.2.0", - "fork-ts-checker-webpack-plugin": "^9.0.0", "jest": "^30.2.0", "jsdom": "^27.1.0", - "mini-css-extract-plugin": "^2.4.2", - "msw": "^1.0.0", - "nodemon": "^3.0.1", - "terser-webpack-plugin": "^5.1.3", - "webpack": "~5.105.0", - "webpack-dev-server": "^5.0.0" - }, - "optionalDependencies": { - "keytar": "^7.9.0" + "nodemon": "^3.0.1" }, "peerDependencies": { "@jest/environment-jsdom-abstract": "^30.0.0", - "@module-federation/enhanced": "^0.21.6", - "@pmmmwh/react-refresh-webpack-plugin": "^0.6.0", - "esbuild-loader": "^4.0.0", - "eslint-webpack-plugin": "^4.2.0", - "fork-ts-checker-webpack-plugin": "^9.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-environment-jsdom": "*", - "jsdom": "^27.1.0", - "mini-css-extract-plugin": "^2.4.2", - "terser-webpack-plugin": "^5.1.3", - "webpack": "~5.105.0", - "webpack-dev-server": "^5.0.0" + "jsdom": "^27.1.0" }, "peerDependenciesMeta": { "@jest/environment-jsdom-abstract": { "optional": true }, - "@module-federation/enhanced": { - "optional": true - }, - "@pmmmwh/react-refresh-webpack-plugin": { - "optional": true - }, - "esbuild-loader": { - "optional": true - }, - "eslint-webpack-plugin": { - "optional": true - }, - "fork-ts-checker-webpack-plugin": { - "optional": true - }, "jest-environment-jsdom": { "optional": true }, "jsdom": { "optional": true - }, - "mini-css-extract-plugin": { - "optional": true - }, - "terser-webpack-plugin": { - "optional": true - }, - "webpack": { - "optional": true - }, - "webpack-dev-server": { - "optional": true } }, "configSchema": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 14da5e791e..483717fc64 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,20 +14,35 @@ * limitations under the License. */ +import chalk from 'chalk'; import { CliInitializer } from './wiring/CliInitializer'; +import { discoverCliModules } from './wiring/discoverCliModules'; (async () => { const initializer = new CliInitializer(); - initializer.add(import('./modules/build')); - initializer.add(import('./modules/config')); - initializer.add(import('./modules/create-github-app')); - initializer.add(import('./modules/info')); - initializer.add(import('./modules/lint')); - initializer.add(import('./modules/maintenance')); - initializer.add(import('./modules/migrate')); - initializer.add(import('./modules/new')); - initializer.add(import('./modules/test')); - initializer.add(import('./modules/translations')); - initializer.add(import('./modules/auth')); + + const discoveredModules = discoverCliModules(); + + if (discoveredModules.length > 0) { + for (const resolvedPath of discoveredModules) { + initializer.add(import(resolvedPath)); + } + } else { + // No CLI modules found in the project root; fall back to the built-in + // set while printing a deprecation warning. + console.error( + chalk.yellow( + `No CLI modules found in the project root dependencies. ` + + `Falling back to the built-in set of modules.\n` + + `This fallback will be removed in a future release. ` + + `Please add @backstage/cli-defaults as a devDependency ` + + `in your root package.json, or install individual ` + + `@backstage/cli-module-* packages for fine-grained control.\n`, + ), + ); + + initializer.add(import('@backstage/cli-defaults')); + } + await initializer.run(); })(); diff --git a/packages/cli/src/wiring/CliInitializer.test.ts b/packages/cli/src/wiring/CliInitializer.test.ts index 10de6d8e09..703ec972fc 100644 --- a/packages/cli/src/wiring/CliInitializer.test.ts +++ b/packages/cli/src/wiring/CliInitializer.test.ts @@ -15,10 +15,28 @@ */ import { CliInitializer } from './CliInitializer'; -import { createCliPlugin } from './factory'; +import { createCliModule } from './factory'; process.exit = jest.fn() as any; +describe('createCliModule', () => { + it('should throw if packageJson has no name', () => { + expect(() => + createCliModule({ + packageJson: { name: '' }, + init: async () => {}, + }), + ).toThrow('The packageJson provided to createCliModule must have a name'); + + expect(() => + createCliModule({ + packageJson: {} as any, + init: async () => {}, + }), + ).toThrow('The packageJson provided to createCliModule must have a name'); + }); +}); + describe('CliInitializer', () => { beforeEach(() => { jest.resetAllMocks(); @@ -28,8 +46,8 @@ describe('CliInitializer', () => { process.argv = ['node', 'cli', 'test']; const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => reg.addCommand({ path: ['test'], @@ -50,8 +68,8 @@ describe('CliInitializer', () => { process.argv = ['node', 'cli', 'test', '[positional]', '']; const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => reg.addCommand({ path: ['test'], @@ -72,8 +90,8 @@ describe('CliInitializer', () => { process.argv = ['node', 'cli', 'test', '--verbose']; const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => reg.addCommand({ path: ['test'], @@ -97,8 +115,8 @@ describe('CliInitializer', () => { process.argv = ['node', 'cli', 'secret']; const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => { reg.addCommand({ path: ['visible'], @@ -124,8 +142,8 @@ describe('CliInitializer', () => { const writeSpy = jest.spyOn(process.stdout, 'write'); const initializer2 = new CliInitializer(); initializer2.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => { reg.addCommand({ path: ['visible'], @@ -152,8 +170,8 @@ describe('CliInitializer', () => { const writeSpy = jest.spyOn(process.stdout, 'write'); const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => { reg.addCommand({ path: ['visible'], @@ -187,8 +205,8 @@ describe('CliInitializer', () => { const writeSpy = jest.spyOn(process.stdout, 'write'); const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => { reg.addCommand({ path: ['group', 'alpha'], @@ -223,8 +241,8 @@ describe('CliInitializer', () => { ]; const initializer = new CliInitializer(); initializer.add( - createCliPlugin({ - pluginId: 'test', + createCliModule({ + packageJson: { name: '@backstage/test' }, init: async reg => reg.addCommand({ path: ['test', 'nested', 'command'], @@ -239,4 +257,120 @@ describe('CliInitializer', () => { await initializer.run(); expect(process.exit).toHaveBeenCalledWith(0); }); + + it('should silently override array-sourced module with conflicting individual module while keeping siblings', async () => { + expect.assertions(3); + process.argv = ['node', 'cli', 'test']; + const initializer = new CliInitializer(); + + const individualModule = createCliModule({ + packageJson: { name: '@backstage/individual' }, + init: async reg => + reg.addCommand({ + path: ['test'], + description: 'individual test', + execute: ({ args }) => { + expect(args).toEqual([]); + return Promise.resolve(); + }, + }), + }); + + const conflictingArrayModule = createCliModule({ + packageJson: { name: '@backstage/array-conflict' }, + init: async reg => + reg.addCommand({ + path: ['test'], + description: 'array test (should be skipped)', + execute: () => Promise.resolve(), + }), + }); + + const nonConflictingArrayModule = createCliModule({ + packageJson: { name: '@backstage/array-sibling' }, + init: async reg => + reg.addCommand({ + path: ['other'], + description: 'other command', + execute: () => Promise.resolve(), + }), + }); + + initializer.add(individualModule); + initializer.add([conflictingArrayModule, nonConflictingArrayModule]); + + await initializer.run(); + expect(process.exit).toHaveBeenCalledWith(0); + + // Verify the sibling command is available by running it + process.argv = ['node', 'cli', 'other']; + const initializer2 = new CliInitializer(); + initializer2.add(individualModule); + initializer2.add([conflictingArrayModule, nonConflictingArrayModule]); + await initializer2.run(); + expect(process.exit).toHaveBeenCalledTimes(2); + }); + + it('should error with package names when two individual modules conflict', async () => { + const initializer = new CliInitializer(); + + initializer.add( + createCliModule({ + packageJson: { name: '@backstage/module-a' }, + init: async reg => + reg.addCommand({ + path: ['conflicting'], + description: 'from module A', + execute: () => Promise.resolve(), + }), + }), + ); + + initializer.add( + createCliModule({ + packageJson: { name: '@backstage/module-b' }, + init: async reg => + reg.addCommand({ + path: ['conflicting'], + description: 'from module B', + execute: () => Promise.resolve(), + }), + }), + ); + + await expect(initializer.run()).rejects.toThrow( + 'Command "conflicting" from "@backstage/module-b" conflicts with an existing command from "@backstage/module-a"', + ); + }); + + it('should error with package names when two array-sourced modules conflict', async () => { + const initializer = new CliInitializer(); + + const moduleA = createCliModule({ + packageJson: { name: '@backstage/array-a' }, + init: async reg => + reg.addCommand({ + path: ['shared'], + description: 'from array A', + execute: () => Promise.resolve(), + }), + }); + + const moduleB = createCliModule({ + packageJson: { name: '@backstage/array-b' }, + init: async reg => + reg.addCommand({ + path: ['shared'], + description: 'from array B', + execute: () => Promise.resolve(), + }), + }); + + initializer.add([moduleA]); + initializer.add([moduleB]); + + await expect(initializer.run()).rejects.toThrow( + 'Command "shared" from "@backstage/array-b" conflicts with an existing command from "@backstage/array-a"', + ); + }); }); diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index d9af1e8a4d..adbdfbe517 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -15,8 +15,13 @@ */ import { CommandGraph } from './CommandGraph'; -import { BackstageCommand, CliFeature, OpaqueCliPlugin } from './types'; -import { CommandRegistry } from './CommandRegistry'; +import { + OpaqueCliModule, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, + isCommandNodeHidden, +} from '@internal/cli'; +import type { CliModule } from '@backstage/cli-node'; import { Command } from 'commander'; import { version } from './version'; import chalk from 'chalk'; @@ -24,46 +29,81 @@ import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; import { isPromise } from 'node:util/types'; -function isNodeHidden( - node: - | { $$type: '@tree/leaf'; command: BackstageCommand } - | { $$type: '@tree/root'; children: unknown[] }, -): boolean { - if (node.$$type === '@tree/leaf') { - return !!node.command.deprecated || !!node.command.experimental; - } - return node.children.every(child => isNodeHidden(child as any)); -} +type UninitializedFeature = + | CliModule + | CliModule[] + | Promise<{ default: CliModule | CliModule[] }>; -type UninitializedFeature = CliFeature | Promise<{ default: CliFeature }>; +interface TaggedFeature { + feature: CliModule; + /** + * Whether this module was sourced from an array (e.g. cli-defaults). + * Array-sourced modules are silently skipped when any of their commands + * overlap with an individually-added module, allowing explicit module + * additions to take precedence without causing conflicts. + */ + fromArray: boolean; +} export class CliInitializer { private graph = new CommandGraph(); - private commandRegistry = new CommandRegistry(this.graph); - #uninitiazedFeatures: Promise[] = []; + #uninitiazedFeatures: Promise[] = []; add(feature: UninitializedFeature) { if (isPromise(feature)) { this.#uninitiazedFeatures.push( - feature.then(f => unwrapFeature(f.default)), + feature.then(f => { + const unwrapped = unwrapFeature(f.default); + if (Array.isArray(unwrapped)) { + return unwrapped.map(m => ({ feature: m, fromArray: true })); + } + return [{ feature: unwrapped, fromArray: false }]; + }), + ); + } else if (Array.isArray(feature)) { + this.#uninitiazedFeatures.push( + Promise.resolve(feature.map(m => ({ feature: m, fromArray: true }))), ); } else { - this.#uninitiazedFeatures.push(Promise.resolve(feature)); + this.#uninitiazedFeatures.push( + Promise.resolve([{ feature, fromArray: false }]), + ); } } - async #register(feature: CliFeature) { - if (OpaqueCliPlugin.isType(feature)) { - const internal = OpaqueCliPlugin.toInternal(feature); - await internal.init(this.commandRegistry); + async #register(feature: CliModule) { + if (OpaqueCliModule.isType(feature)) { + for (const command of await OpaqueCliModule.toInternal(feature) + .commands) { + this.graph.add(command, feature); + } } else { throw new Error(`Unsupported feature type: ${(feature as any).$$type}`); } } async #doInit() { - const features = await Promise.all(this.#uninitiazedFeatures); - for (const feature of features) { + const resolvedGroups = await Promise.all(this.#uninitiazedFeatures); + const allFeatures = resolvedGroups.flat(); + + // Collect command paths from individually-added modules + const individualPaths = new Set(); + for (const { feature, fromArray } of allFeatures) { + if (!fromArray && OpaqueCliModule.isType(feature)) { + const cmds = await OpaqueCliModule.toInternal(feature).commands; + for (const cmd of cmds) { + individualPaths.add(cmd.path.join(' ')); + } + } + } + + for (const { feature, fromArray } of allFeatures) { + if (fromArray && OpaqueCliModule.isType(feature)) { + const cmds = await OpaqueCliModule.toInternal(feature).commands; + if (cmds.some(cmd => individualPaths.has(cmd.path.join(' ')))) { + continue; + } + } await this.#register(feature); } } @@ -89,25 +129,28 @@ export class CliInitializer { })); while (queue.length) { const { node, argParser } = queue.shift()!; - if (node.$$type === '@tree/root') { + if (OpaqueCommandTreeNode.isType(node)) { + const internal = OpaqueCommandTreeNode.toInternal(node); const treeParser = argParser - .command(`${node.name} [command]`, { - hidden: isNodeHidden(node), + .command(`${internal.name} [command]`, { + hidden: isCommandNodeHidden(node), }) - .description(node.name); + .description(internal.name); queue.push( - ...node.children.map(child => ({ + ...internal.children.map(child => ({ node: child, argParser: treeParser, })), ); } else { + const internal = OpaqueCommandLeafNode.toInternal(node); argParser - .command(node.name, { - hidden: !!node.command.deprecated || !!node.command.experimental, + .command(internal.name, { + hidden: + !!internal.command.deprecated || !!internal.command.experimental, }) - .description(node.command.description) + .description(internal.command.description) .helpOption(false) .allowUnknownOption(true) .allowExcessArguments(true) @@ -126,7 +169,7 @@ export class CliInitializer { // Skip the command name if ( argIndex === index && - node.command.path[argIndex] === nonProcessArgs[argIndex] + internal.command.path[argIndex] === nonProcessArgs[argIndex] ) { index += 1; continue; @@ -136,15 +179,15 @@ export class CliInitializer { const context = { args: [...positionalArgs, ...args.unknown], info: { - usage: [programName, ...node.command.path].join(' '), - description: node.command.description, + usage: [programName, ...internal.command.path].join(' '), + name: internal.command.path.join(' '), }, }; - if (typeof node.command.execute === 'function') { - await node.command.execute(context); + if (typeof internal.command.execute === 'function') { + await internal.command.execute(context); } else { - const mod = await node.command.execute.loader(); + const mod = await internal.command.execute.loader(); // Handle CJS double-wrapping of default exports const fn = typeof mod.default === 'function' @@ -177,8 +220,12 @@ export class CliInitializer { /** @internal */ export function unwrapFeature( - feature: CliFeature | { default: CliFeature }, -): CliFeature { + feature: CliModule | CliModule[] | { default: CliModule | CliModule[] }, +): CliModule | CliModule[] { + if (Array.isArray(feature)) { + return feature; + } + if ('$$type' in feature) { return feature; } diff --git a/packages/cli/src/wiring/CommandGraph.ts b/packages/cli/src/wiring/CommandGraph.ts index a0a2de3305..b201a3fba3 100644 --- a/packages/cli/src/wiring/CommandGraph.ts +++ b/packages/cli/src/wiring/CommandGraph.ts @@ -13,91 +13,146 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { BackstageCommand } from './types'; - -type Node = TreeNode | LeafNode; - -interface TreeNode { - $$type: '@tree/root'; - name: string; - children: TreeNode[]; -} - -interface LeafNode { - $$type: '@tree/leaf'; - name: string; - command: BackstageCommand; -} +import { + CommandNode, + OpaqueCommandTreeNode, + OpaqueCommandLeafNode, + OpaqueCliModule, +} from '@internal/cli'; +import { CliCommand, CliModule } from './types'; /** * A sparse graph of commands. */ export class CommandGraph { - private graph: Node[] = []; + private graph: CommandNode[] = []; /** * Adds a command to the graph. The graph is sparse, so we use the path to determine the nodes * to traverse. Only leaf nodes should have a command/action. */ - add(command: BackstageCommand) { + add(command: CliCommand, module?: CliModule) { const path = command.path; let current = this.graph; for (let i = 0; i < path.length - 1; i++) { const name = path[i]; - let next = current.find(n => n.name === name); + let next = current.find( + n => + (OpaqueCommandTreeNode.isType(n) && + OpaqueCommandTreeNode.toInternal(n).name === name) || + (OpaqueCommandLeafNode.isType(n) && + OpaqueCommandLeafNode.toInternal(n).name === name), + ); if (!next) { - next = { $$type: '@tree/root', name, children: [] }; + next = OpaqueCommandTreeNode.createInstance('v1', { + name, + children: [], + }); current.push(next); - } else if (next.$$type === '@tree/leaf') { + } else if (OpaqueCommandLeafNode.isType(next)) { throw new Error( - `Command already exists at path: "${path.slice(0, i).join(' ')}"`, + formatConflictError( + path, + module, + OpaqueCommandLeafNode.toInternal(next).module, + ), ); } - current = next.children; + current = OpaqueCommandTreeNode.toInternal(next).children; } - const last = current.find(n => n.name === path[path.length - 1]); - if (last && last.$$type === '@tree/leaf') { + const lastName = path[path.length - 1]; + const last = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === lastName; + } + return OpaqueCommandLeafNode.toInternal(n).name === lastName; + }); + if (last && OpaqueCommandLeafNode.isType(last)) { throw new Error( - `Command already exists at path: "${path.slice(0, -1).join(' ')}"`, + formatConflictError( + path, + module, + OpaqueCommandLeafNode.toInternal(last).module, + ), ); } else { - current.push({ - $$type: '@tree/leaf', - name: path[path.length - 1], - command, - }); + current.push( + OpaqueCommandLeafNode.createInstance('v1', { + name: lastName, + command, + module, + }), + ); } } /** * Given a path, try to find a command that matches it. */ - find(path: string[]): BackstageCommand | undefined { + find(path: string[]): CliCommand | undefined { let current = this.graph; for (let i = 0; i < path.length - 1; i++) { const name = path[i]; - const next = current.find(n => n.name === name); - if (!next) { - return undefined; - } else if (next.$$type === '@tree/leaf') { + const next = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === name; + } + return OpaqueCommandLeafNode.toInternal(n).name === name; + }); + if (!next || OpaqueCommandLeafNode.isType(next)) { return undefined; } - current = next.children; + current = OpaqueCommandTreeNode.toInternal(next).children; } - const last = current.find(n => n.name === path[path.length - 1]); - if (!last || last.$$type === '@tree/root') { + const lastName = path[path.length - 1]; + const last = current.find(n => { + if (OpaqueCommandTreeNode.isType(n)) { + return OpaqueCommandTreeNode.toInternal(n).name === lastName; + } + return OpaqueCommandLeafNode.toInternal(n).name === lastName; + }); + if (!last || OpaqueCommandTreeNode.isType(last)) { return undefined; } - return last?.command; + return OpaqueCommandLeafNode.toInternal(last).command; } - atDepth(depth: number): Node[] { + atDepth(depth: number): CommandNode[] { let current = this.graph; for (let i = 0; i < depth; i++) { current = current.flatMap(n => - n.$$type === '@tree/root' ? n.children : [], + OpaqueCommandTreeNode.isType(n) + ? OpaqueCommandTreeNode.toInternal(n).children + : [], ); } return current; } } + +function getModuleName(module?: CliModule): string | undefined { + if (module && OpaqueCliModule.isType(module)) { + return OpaqueCliModule.toInternal(module).packageName; + } + return undefined; +} + +function formatConflictError( + path: string[], + newModule?: CliModule, + existingModule?: CliModule, +): string { + const cmd = path.join(' '); + const newPkg = getModuleName(newModule); + const existingPkg = getModuleName(existingModule); + if (newPkg && existingPkg) { + return `Command "${cmd}" from "${newPkg}" conflicts with an existing command from "${existingPkg}"`; + } + if (newPkg) { + return `Command "${cmd}" from "${newPkg}" conflicts with an existing command`; + } + if (existingPkg) { + return `Command "${cmd}" conflicts with an existing command from "${existingPkg}"`; + } + return `Command "${cmd}" conflicts with an existing command`; +} diff --git a/packages/cli/src/wiring/CommandRegistry.ts b/packages/cli/src/wiring/CommandRegistry.ts index 9e8ff9646c..c3c1f1956d 100644 --- a/packages/cli/src/wiring/CommandRegistry.ts +++ b/packages/cli/src/wiring/CommandRegistry.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { CommandGraph } from './CommandGraph'; -import { BackstageCommand } from './types'; +import { CliCommand } from './types'; export class CommandRegistry { private graph: CommandGraph; @@ -22,7 +22,7 @@ export class CommandRegistry { this.graph = graph; } - addCommand(command: BackstageCommand) { + addCommand(command: CliCommand) { this.graph.add(command); } } diff --git a/packages/cli/src/wiring/discoverCliModules.ts b/packages/cli/src/wiring/discoverCliModules.ts new file mode 100644 index 0000000000..e2945d2631 --- /dev/null +++ b/packages/cli/src/wiring/discoverCliModules.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { targetPaths } from '@backstage/cli-common'; +import { PackageRoles } from '@backstage/cli-node'; +import fs from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * Scans the target project root's package.json for dependencies that are CLI + * modules (packages with `backstage.role === 'cli-module'`). + * + * Returns the resolved entry point paths of discovered CLI module packages, + * or an empty array if none are found or the project root cannot be read. + * The paths are resolved relative to the project root to ensure they can be + * imported regardless of where the CLI code itself is located. + */ +export function discoverCliModules(): string[] { + const rootDir = targetPaths.rootDir; + const pkgJsonPath = resolvePath(rootDir, 'package.json'); + + let projectPkg: { + dependencies?: Record; + devDependencies?: Record; + }; + try { + projectPkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + } catch { + return []; + } + + const allDeps = { + ...projectPkg.dependencies, + ...projectPkg.devDependencies, + }; + + const modules: string[] = []; + + for (const depName of Object.keys(allDeps)) { + try { + const depPkgPath = require.resolve(`${depName}/package.json`, { + paths: [rootDir], + }); + const depPkg = JSON.parse(fs.readFileSync(depPkgPath, 'utf8')); + if (PackageRoles.getRoleFromPackage(depPkg) === 'cli-module') { + const resolvedPath = require.resolve(depName, { paths: [rootDir] }); + modules.push(pathToFileURL(resolvedPath).href); + } + } catch { + // Skip packages that can't be resolved or read + } + } + + return modules; +} diff --git a/packages/cli/src/wiring/factory.ts b/packages/cli/src/wiring/factory.ts index 6374f37c14..f8f04715c3 100644 --- a/packages/cli/src/wiring/factory.ts +++ b/packages/cli/src/wiring/factory.ts @@ -14,18 +14,4 @@ * limitations under the License. */ -import { describeParentCallSite } from './describeParentCallSite'; -import { BackstageCommand, CliPlugin, OpaqueCliPlugin } from './types'; - -export function createCliPlugin(options: { - pluginId: string; - init: (registry: { - addCommand: (command: BackstageCommand) => void; - }) => Promise; -}): CliPlugin { - return OpaqueCliPlugin.createInstance('v1', { - pluginId: options.pluginId, - init: options.init, - description: `created at '${describeParentCallSite()}'`, - }); -} +export { createCliModule } from '@backstage/cli-node'; diff --git a/packages/cli/src/wiring/types.ts b/packages/cli/src/wiring/types.ts index fa3d2341f0..7a8a009a60 100644 --- a/packages/cli/src/wiring/types.ts +++ b/packages/cli/src/wiring/types.ts @@ -13,53 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { OpaqueType } from '@internal/opaque'; -export interface CommandContext { - args: string[]; - info: { - /** - * The usage string of the current command, for example: "backstage-cli repo test" - */ - usage: string; - /** - * The description provided for the command - */ - description: string; - }; -} - -export type CommandExecuteFn = (context: CommandContext) => Promise; - -export interface BackstageCommand { - path: string[]; - description: string; - deprecated?: boolean; - experimental?: boolean; - execute: - | CommandExecuteFn - | { - loader: () => Promise<{ default: CommandExecuteFn }>; - }; -} - -export type CliFeature = CliPlugin; - -export interface CliPlugin { - readonly pluginId: string; - readonly $$type: '@backstage/CliPlugin'; -} - -export const OpaqueCliPlugin = OpaqueType.create<{ - public: CliPlugin; - versions: { - readonly version: 'v1'; - readonly description: string; - init: (registry: { - addCommand: (command: BackstageCommand) => void; - }) => Promise; - }; -}>({ - type: '@backstage/CliPlugin', - versions: ['v1'], -}); +export type { + CliCommandContext, + CliCommand, + CliModule, +} from '@backstage/cli-node'; diff --git a/packages/cli/templates/new-frontend-plugin/dev/index.tsx b/packages/cli/templates/new-frontend-plugin/dev/index.tsx index e1bcb0401e..21f842925b 100644 --- a/packages/cli/templates/new-frontend-plugin/dev/index.tsx +++ b/packages/cli/templates/new-frontend-plugin/dev/index.tsx @@ -1,10 +1,5 @@ -import { createApp } from '@backstage/frontend-defaults'; -import ReactDOM from 'react-dom'; +import { createDevApp } from '@backstage/frontend-dev-utils'; import plugin from '../src'; -const app = createApp({ - features: [plugin], -}); - -ReactDOM.render(app.createRoot(), document.getElementById('root')); +createDevApp({ features: [plugin] }); diff --git a/packages/cli/templates/new-frontend-plugin/package.json.hbs b/packages/cli/templates/new-frontend-plugin/package.json.hbs index 7ee1aed5e9..4a37a4fa63 100644 --- a/packages/cli/templates/new-frontend-plugin/package.json.hbs +++ b/packages/cli/templates/new-frontend-plugin/package.json.hbs @@ -35,7 +35,7 @@ }, "devDependencies": { "@backstage/cli": "{{versionQuery '@backstage/cli'}}", - "@backstage/frontend-defaults": "{{versionQuery '@backstage/frontend-defaults'}}", + "@backstage/frontend-dev-utils": "{{versionQuery '@backstage/frontend-dev-utils'}}", "@backstage/frontend-test-utils": "{{versionQuery '@backstage/frontend-test-utils'}}", "@testing-library/jest-dom": "{{versionQuery '@testing-library/jest-dom' '6.0.0'}}", "@testing-library/react": "{{versionQuery '@testing-library/react' '14.0.0'}}", diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx index afc87d6753..337792d71b 100644 --- a/packages/core-app-api/src/app/AppRouter.tsx +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -23,7 +23,9 @@ import { SignInPageProps, useApi, useApp, + useAnalytics, } from '@backstage/core-plugin-api'; +import { BUIProvider } from '@backstage/ui'; import { InternalAppContext } from './InternalAppContext'; import { isReactRouterBeta } from './isReactRouterBeta'; import { RouteTracker } from '../routing/RouteTracker'; @@ -143,18 +145,22 @@ export function AppRouter(props: AppRouterProps) { if (isReactRouterBeta()) { return ( - - - {props.children}} /> - + + + + {props.children}} /> + + ); } return ( - - {props.children} + + + {props.children} + ); } @@ -162,28 +168,32 @@ export function AppRouter(props: AppRouterProps) { if (isReactRouterBeta()) { return ( - - - - {props.children}} /> - - + + + + + {props.children}} /> + + + ); } return ( - - - {props.children} - + + + + {props.children} + + ); } diff --git a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index 328ab50a34..a34ae21892 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -20,10 +20,11 @@ import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import ListItemText from '@material-ui/core/ListItemText'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; -import { useState } from 'react'; +import { createElement, isValidElement, useState } from 'react'; import { isError } from '@backstage/errors'; import { configApiRef, + IconComponent, PendingOAuthRequest, useApi, } from '@backstage/core-plugin-api'; @@ -68,7 +69,7 @@ const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => { } }; - const IconComponent = request.provider.icon; + const providerIcon = request.provider.icon; const message = request.provider.message ?? t('oauthRequestDialog.message', { @@ -76,11 +77,14 @@ const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => { provider: request.provider.title, }); + const iconElement = + providerIcon === null || isValidElement(providerIcon) + ? providerIcon + : createElement(providerIcon as IconComponent, { fontSize: 'large' }); + return ( - - - + {iconElement ?? <>} font: 'inherit', textTransform: 'none', }, + itemIcon: { + display: 'inline-flex', + fontSize: theme.typography.fontSize, + lineHeight: 0, + '& svg': { + width: '1.5em', + height: '1.5em', + fontSize: 'inherit', + flexShrink: 0, + }, + }, closed: { width: sidebarConfig.drawerWidthClosed, justifyContent: 'center', @@ -401,7 +412,9 @@ const SidebarItemBase = forwardRef< const displayItemIcon = ( - + + + {!isOpen && hasSubmenu ? : <>} ); diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts index be6852e3ab..6416681b27 100644 --- a/packages/create-app/src/lib/tasks.test.ts +++ b/packages/create-app/src/lib/tasks.test.ts @@ -50,6 +50,7 @@ jest.mock('./versions', () => ({ packageVersions: { root: '1.2.3', '@backstage/cli': '1.0.0', + '@backstage/cli-defaults': '1.0.0', '@backstage/backend-defaults': '1.0.0', '@backstage/backend-tasks': '1.0.0', '@backstage/catalog-model': '1.0.0', diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index db865a2db5..006dd6d7b8 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -38,6 +38,7 @@ import { version as backendDefaults } from '../../../backend-defaults/package.js import { version as catalogClient } from '../../../catalog-client/package.json'; import { version as catalogModel } from '../../../catalog-model/package.json'; import { version as cli } from '../../../cli/package.json'; +import { version as cliDefaults } from '../../../cli-defaults/package.json'; import { version as config } from '../../../config/package.json'; import { version as coreAppApi } from '../../../core-app-api/package.json'; import { version as coreCompatApi } from '../../../core-compat-api/package.json'; @@ -107,6 +108,7 @@ export const packageVersions = { '@backstage/catalog-client': catalogClient, '@backstage/catalog-model': catalogModel, '@backstage/cli': cli, + '@backstage/cli-defaults': cliDefaults, '@backstage/config': config, '@backstage/core-app-api': coreAppApi, '@backstage/core-compat-api': coreCompatApi, diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 229ed5ffa0..4262e0fc82 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -28,6 +28,7 @@ ], "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", + "@backstage/cli-defaults": "^{{version '@backstage/cli-defaults'}}", "@backstage/e2e-test-utils": "^{{version '@backstage/e2e-test-utils'}}", "@jest/environment-jsdom-abstract": "^30.0.0", "@playwright/test": "^1.32.3", diff --git a/packages/create-app/templates/next-app/package.json.hbs b/packages/create-app/templates/next-app/package.json.hbs index 27d5880c11..49436f5fcf 100644 --- a/packages/create-app/templates/next-app/package.json.hbs +++ b/packages/create-app/templates/next-app/package.json.hbs @@ -50,6 +50,7 @@ ], "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", + "@backstage/cli-defaults": "^{{version '@backstage/cli-defaults'}}", "@backstage/e2e-test-utils": "^{{version '@backstage/e2e-test-utils'}}", "@jest/environment-jsdom-abstract": "^30.0.0", "@playwright/test": "^1.32.3", diff --git a/packages/e2e-test/src/commands/runCommand.ts b/packages/e2e-test/src/commands/runCommand.ts index da39351400..a86e7f303d 100644 --- a/packages/e2e-test/src/commands/runCommand.ts +++ b/packages/e2e-test/src/commands/runCommand.ts @@ -383,9 +383,13 @@ async function createPlugin(options: { try { let stdout = ''; + let stderr = ''; child.stdout?.on('data', (data: Buffer) => { stdout = stdout + data.toString('utf8'); }); + child.stderr?.on('data', (data: Buffer) => { + stderr = stderr + data.toString('utf8'); + }); print('Waiting for plugin create script to be done'); await child.waitForExit(); diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts index cfe6236b95..558c160ecb 100644 --- a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts +++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts @@ -59,6 +59,8 @@ describe('DefaultIconsApi', () => { expect(result).toBeTruthy(); // @ts-expect-error accessing internal React element structure expect(result.type).toBe(MyIcon); + // @ts-expect-error accessing internal React element structure + expect(result.props.fontSize).toBe('inherit'); }); it('should wrap IconElement values in a component for getIcon()', () => { @@ -69,10 +71,43 @@ describe('DefaultIconsApi', () => { expect(icon).toBeDefined(); expect(typeof icon).toBe('function'); // @ts-expect-error testing runtime behavior - expect(icon({})).toBe(element); + const result = icon({}); + expect(result.type).toBe('span'); + expect(result.props.style).toEqual({ fontSize: '1.5rem' }); + expect(result.props.children).toBe(element.props.children); expect(api.getIcon('myIcon')).toBe(icon); }); + it('should honor fontSize for getIcon()', () => { + const element = createElement('svg'); + const api = new DefaultIconsApi({ myIcon: element }); + const icon = api.getIcon('myIcon'); + + // @ts-expect-error testing runtime behavior + const result = icon({ fontSize: 'small' }); + expect(result.props.style.fontSize).toBe('1.25rem'); + }); + + it('should forward runtime props to the original icon element', () => { + const element = createElement('svg', { + className: 'existing', + style: { color: 'red' }, + }); + const api = new DefaultIconsApi({ myIcon: element }); + const icon = api.getIcon('myIcon'); + + // @ts-expect-error testing runtime behavior + const result = icon({ className: 'extra', style: { width: '2em' } }); + + expect(result.type).toBe('svg'); + expect(result.props.className).toBe('existing extra'); + expect(result.props.style).toEqual({ + color: 'red', + fontSize: '1.5rem', + width: '2em', + }); + }); + it('should wrap null IconElement in a component for getIcon()', () => { const api = new DefaultIconsApi({ empty: null }); diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts index cc5ef69b6f..52ac07551a 100644 --- a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts @@ -19,7 +19,22 @@ import { IconElement, IconsApi, } from '@backstage/frontend-plugin-api'; -import { createElement, isValidElement } from 'react'; +import { cloneElement, createElement, isValidElement } from 'react'; + +const legacyFontSizeMap = { + inherit: 'inherit', + small: '1.25rem', + medium: '1.5rem', + large: '2.1875rem', +} as const; + +function mergeClassNames(...classNames: Array) { + const merged = classNames.filter(Boolean).join(' '); + if (merged) { + return merged; + } + return undefined; +} /** * Implementation for the {@link IconsApi} @@ -39,7 +54,10 @@ export class DefaultIconsApi implements IconsApi { return [key, value]; } deprecatedKeys.push(key); - return [key, createElement(value as IconComponent)]; + return [ + key, + createElement(value as IconComponent, { fontSize: 'inherit' }), + ]; }), ); @@ -65,7 +83,37 @@ export class DefaultIconsApi implements IconsApi { if (el === undefined) { return undefined; } - component = () => el; + component = props => { + if (el === null) { + return null; + } + + const { + fontSize = 'medium', + className, + style, + ...rest + } = props as { + fontSize?: keyof typeof legacyFontSizeMap; + className?: string; + style?: Record; + } & Record; + + const elementProps = el.props as { + className?: string; + style?: Record; + }; + + return cloneElement(el, { + ...rest, + className: mergeClassNames(elementProps.className, className), + style: { + ...elementProps.style, + fontSize: legacyFontSizeMap[fontSize], + ...style, + }, + }); + }; this.#components.set(key, component); return component; } diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index fcef41daec..e52bbfa320 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -17,11 +17,10 @@ import { AppNode, Extension, + ExtensionDataContainer, ExtensionDataRef, ExtensionDefinition, - ExtensionInput, PortableSchema, - ResolvedExtensionInput, createExtension, createExtensionBlueprint, createExtensionDataRef, @@ -146,8 +145,8 @@ function mirrorInputs(ctx: { inputs: { [name in string]: | undefined - | ResolvedExtensionInput - | Array>; + | ({ node: AppNode } & ExtensionDataContainer) + | Array<{ node: AppNode } & ExtensionDataContainer>; }; }) { return [ diff --git a/packages/frontend-dev-utils/.eslintrc.js b/packages/frontend-dev-utils/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/frontend-dev-utils/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/frontend-dev-utils/README.md b/packages/frontend-dev-utils/README.md new file mode 100644 index 0000000000..5a590c1d58 --- /dev/null +++ b/packages/frontend-dev-utils/README.md @@ -0,0 +1,19 @@ +# @backstage/frontend-dev-utils + +Utilities for developing Backstage frontend plugins using the new frontend system. + +This package provides a minimal helper for wiring up a development app from a `dev/` entry point, making it easy to run and test frontend plugins in isolation. + +## Installation + +Install the package via Yarn: + +```sh +cd plugins/ # if within a monorepo +yarn add -D @backstage/frontend-dev-utils +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/frontend-dev-utils/catalog-info.yaml b/packages/frontend-dev-utils/catalog-info.yaml new file mode 100644 index 0000000000..b05c739594 --- /dev/null +++ b/packages/frontend-dev-utils/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-frontend-dev-utils + title: '@backstage/frontend-dev-utils' + description: Utilities for developing Backstage frontend plugins using the new frontend system. +spec: + lifecycle: experimental + type: backstage-web-library + owner: framework-maintainers diff --git a/packages/frontend-dev-utils/knip-report.md b/packages/frontend-dev-utils/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/packages/frontend-dev-utils/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/packages/frontend-dev-utils/package.json b/packages/frontend-dev-utils/package.json new file mode 100644 index 0000000000..33eadabbe2 --- /dev/null +++ b/packages/frontend-dev-utils/package.json @@ -0,0 +1,65 @@ +{ + "name": "@backstage/frontend-dev-utils", + "version": "0.0.0", + "description": "Utilities for developing Backstage frontend plugins using the new frontend system.", + "backstage": { + "role": "web-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/frontend-dev-utils" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/frontend-defaults": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-app": "workspace:^", + "@backstage/ui": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.30.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.30.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } +} diff --git a/packages/frontend-dev-utils/report.api.md b/packages/frontend-dev-utils/report.api.md new file mode 100644 index 0000000000..39ca2aec2b --- /dev/null +++ b/packages/frontend-dev-utils/report.api.md @@ -0,0 +1,18 @@ +## API Report File for "@backstage/frontend-dev-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CreateAppOptions } from '@backstage/frontend-defaults'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; +import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; + +// @public +export function createDevApp(options: CreateDevAppOptions): void; + +// @public +export interface CreateDevAppOptions { + bindRoutes?: CreateAppOptions['bindRoutes']; + features: (FrontendFeature | FrontendFeatureLoader)[]; +} +``` diff --git a/packages/frontend-dev-utils/src/BuiCss.tsx b/packages/frontend-dev-utils/src/BuiCss.tsx new file mode 100644 index 0000000000..d5ece19b71 --- /dev/null +++ b/packages/frontend-dev-utils/src/BuiCss.tsx @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This ensures that dev apps always have the BUI CSS loaded. +// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend +import '@backstage/ui/css/styles.css'; + +/** + * Placeholder component to allow lazy loading of the BUI CSS import. This + * ensures that we don't load the CSS as soon as anyone imports this package. + */ +export default () => null; diff --git a/packages/frontend-dev-utils/src/createDevApp.test.tsx b/packages/frontend-dev-utils/src/createDevApp.test.tsx new file mode 100644 index 0000000000..5c0b21ab25 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.test.tsx @@ -0,0 +1,193 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + PageBlueprint, + createFrontendPlugin, +} from '@backstage/frontend-plugin-api'; +import { waitFor, within } from '@testing-library/react'; +import { createDevApp } from './createDevApp'; + +jest.setTimeout(15000); + +const originalEnv = process.env; + +function loadCreateDevAppIsolated(): typeof import('./createDevApp').createDevApp { + let isolatedCreateDevApp: + | typeof import('./createDevApp').createDevApp + | undefined; + + jest.isolateModules(() => { + ({ createDevApp: isolatedCreateDevApp } = require('./createDevApp')); + }); + + if (!isolatedCreateDevApp) { + throw new Error('Expected createDevApp to be loaded in isolation'); + } + + return isolatedCreateDevApp; +} + +describe('createDevApp', () => { + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + jest.resetAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should render a dev app with a plugin', async () => { + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + const testPlugin = createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + params: { + path: '/', + loader: async () =>
Test Plugin Page
, + }, + }), + ], + }); + + (process.env as any).APP_CONFIG = [ + { + context: 'test', + data: { + app: { title: 'Test App' }, + backend: { baseUrl: 'http://localhost' }, + }, + }, + ]; + + createDevApp({ + features: [testPlugin], + }); + + const body = within(document.body); + await body.findByText('Test Plugin Page', {}, { timeout: 10000 }); + }); + + it('should forward bindRoutes to createApp', async () => { + jest.resetModules(); + + const bindRoutes = jest.fn(); + const createApp = jest.fn(() => ({ + createRoot: () =>
Test App Root
, + })); + const render = jest.fn(); + const createRoot = jest.fn(() => ({ render })); + + jest.doMock('@backstage/frontend-defaults', () => ({ + createApp, + })); + jest.doMock('@backstage/plugin-app', () => ({ + __esModule: true, + default: { + withOverrides: jest.fn(() => 'app-plugin-override'), + getExtension: jest.fn(() => ({ + override: jest.fn(() => 'disabled-sign-in-page'), + })), + }, + })); + jest.doMock('react-dom/client', () => ({ + __esModule: true, + createRoot, + })); + + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + const isolatedCreateDevApp = loadCreateDevAppIsolated(); + isolatedCreateDevApp({ + bindRoutes, + features: ['plugin-feature'] as any, + }); + + await waitFor(() => { + expect(createApp).toHaveBeenCalledWith({ + bindRoutes, + features: ['app-plugin-override', 'plugin-feature'], + }); + expect(createRoot).toHaveBeenCalledWith(root); + }); + + const renderedNode = render.mock.calls[0][0] as any; + expect(renderedNode.props.children).toHaveLength(2); + expect(renderedNode.props.children[0].props.fallback).toBeNull(); + expect(renderedNode.props.children[1].props.children).toBe('Test App Root'); + }); + + it('should throw a clear error when the root element is missing', () => { + expect(() => createDevApp({ features: [] })).toThrow( + "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.", + ); + }); + + it('should fall back to legacy react-dom rendering when createRoot is unavailable', async () => { + jest.resetModules(); + delete process.env.HAS_REACT_DOM_CLIENT; + + const createApp = jest.fn(() => ({ + createRoot: () =>
Test App Root
, + })); + const render = jest.fn(); + + jest.doMock('@backstage/frontend-defaults', () => ({ + createApp, + })); + jest.doMock('@backstage/plugin-app', () => ({ + __esModule: true, + default: { + withOverrides: jest.fn(() => 'app-plugin-override'), + getExtension: jest.fn(() => ({ + override: jest.fn(() => 'disabled-sign-in-page'), + })), + }, + })); + jest.doMock('react-dom', () => ({ + __esModule: true, + render, + })); + + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); + + const isolatedCreateDevApp = loadCreateDevAppIsolated(); + isolatedCreateDevApp({ + features: ['plugin-feature'] as any, + }); + + await waitFor(() => { + expect(render).toHaveBeenCalled(); + expect(createApp).toHaveBeenCalledWith({ + bindRoutes: undefined, + features: ['app-plugin-override', 'plugin-feature'], + }); + }); + }); +}); diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx new file mode 100644 index 0000000000..80cc95dc22 --- /dev/null +++ b/packages/frontend-dev-utils/src/createDevApp.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FrontendFeature, + FrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; +import { createApp, CreateAppOptions } from '@backstage/frontend-defaults'; +import appPlugin from '@backstage/plugin-app'; +import { Suspense, lazy } from 'react'; +import 'react-dom'; + +type AppPluginWithSimpleOverrides = { + withOverrides(options: { extensions: unknown[] }): FrontendFeature; +}; + +// Collapse the deeply nested override types to avoid excessive instantiation. +const appPluginOverride = ( + appPlugin as unknown as AppPluginWithSimpleOverrides +).withOverrides({ + extensions: [ + appPlugin.getExtension('sign-in-page:app').override({ + disabled: true, + }), + ], +}); + +const BuiCss = lazy(() => import('./BuiCss')); + +let ReactDOMPromise: Promise< + typeof import('react-dom') | typeof import('react-dom/client') +>; +if (process.env.HAS_REACT_DOM_CLIENT) { + ReactDOMPromise = import('react-dom/client'); +} else { + ReactDOMPromise = import('react-dom'); +} + +/** + * Options for {@link createDevApp}. + * + * @public + */ +export interface CreateDevAppOptions { + /** + * The list of features to load in the dev app. + */ + features: (FrontendFeature | FrontendFeatureLoader)[]; + + /** + * Allows for the binding of plugins' external route refs within the dev app. + */ + bindRoutes?: CreateAppOptions['bindRoutes']; +} + +function getRootElement(): HTMLElement { + const rootElement = document.getElementById('root'); + + if (!rootElement) { + throw new Error( + "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.", + ); + } + + return rootElement; +} + +/** + * Creates and renders a minimal development app for the new frontend system. + * + * @example + * ```tsx + * // dev/index.ts + * import { createDevApp } from '@backstage/frontend-dev-utils'; + * import myPlugin from '../src'; + * + * createDevApp({ features: [myPlugin] }); + * ``` + * + * @public + */ +export function createDevApp(options: CreateDevAppOptions): void { + const rootElement = getRootElement(); + const { features, bindRoutes } = options; + const devFeatures: CreateAppOptions['features'] = [ + appPluginOverride, + ...features, + ]; + const appOptions: CreateAppOptions = { + bindRoutes, + features: devFeatures, + }; + const app = createApp(appOptions); + const AppRoot = app.createRoot(); + + ReactDOMPromise.then(ReactDOM => { + const rootNode = ( + <> + + + + {AppRoot} + + ); + + if ('createRoot' in ReactDOM) { + ReactDOM.createRoot(rootElement).render(rootNode); + } else { + ReactDOM.render(rootNode, rootElement); + } + }); +} diff --git a/packages/frontend-dev-utils/src/index.ts b/packages/frontend-dev-utils/src/index.ts new file mode 100644 index 0000000000..d93fa3d473 --- /dev/null +++ b/packages/frontend-dev-utils/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Utilities for developing Backstage frontend plugins using the new frontend system. + * + * @packageDocumentation + */ + +export { createDevApp, type CreateDevAppOptions } from './createDevApp'; diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 77a377bb8e..76678107ef 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -11,8 +11,11 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ReactNode } from 'react'; -// @alpha +// @public export type PluginWrapperApi = { + getRootWrapper(): ComponentType<{ + children: ReactNode; + }>; getPluginWrapper(pluginId: string): | ComponentType<{ children: ReactNode; @@ -20,31 +23,19 @@ export type PluginWrapperApi = { | undefined; }; -// @alpha +// @public export const pluginWrapperApiRef: ApiRef; -// @alpha +// @public export const PluginWrapperBlueprint: ExtensionBlueprint<{ kind: 'plugin-wrapper'; - params: (params: { - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; + params: (params: { + loader: () => Promise>; }) => ExtensionBlueprintParams<{ - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; + loader: () => Promise; }>; output: ExtensionDataRef< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, + () => Promise, 'core.plugin-wrapper.loader', {} >; @@ -53,16 +44,21 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{ configInput: {}; dataRefs: { wrapper: ConfigurableExtensionDataRef< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, + () => Promise, 'core.plugin-wrapper.loader', {} >; }; }>; +// @public +export type PluginWrapperDefinition = { + useWrapperValue?: () => TValue; + component: ComponentType<{ + children: ReactNode; + value: TValue; + }>; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 4624e68dda..768f26c0eb 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -316,7 +316,7 @@ export const atlassianAuthApiRef: ApiRef< export type AuthProviderInfo = { id: string; title: string; - icon: IconComponent; + icon: IconComponent | IconElement; message?: string; }; @@ -1156,12 +1156,6 @@ export type ExtensionDataRef< readonly config: TConfig; }; -// @public (undocumented) -export type ExtensionDataRefToValue = - TDataRef extends ExtensionDataRef - ? ExtensionDataValue - : never; - // @public (undocumented) export type ExtensionDataValue = { readonly $$type: '@backstage/ExtensionDataValue'; @@ -1880,6 +1874,55 @@ export interface PluginOptions< title?: string; } +// @public +export type PluginWrapperApi = { + getRootWrapper(): ComponentType<{ + children: ReactNode; + }>; + getPluginWrapper(pluginId: string): + | ComponentType<{ + children: ReactNode; + }> + | undefined; +}; + +// @public +export const pluginWrapperApiRef: ApiRef_2; + +// @public +export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ + kind: 'plugin-wrapper'; + params: (params: { + loader: () => Promise>; + }) => ExtensionBlueprintParams_2<{ + loader: () => Promise; + }>; + output: ExtensionDataRef_2< + () => Promise, + 'core.plugin-wrapper.loader', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + wrapper: ConfigurableExtensionDataRef_2< + () => Promise, + 'core.plugin-wrapper.loader', + {} + >; + }; +}>; + +// @public +export type PluginWrapperDefinition = { + useWrapperValue?: () => TValue; + component: ComponentType<{ + children: ReactNode; + value: TValue; + }>; +}; + // @public (undocumented) export type PortableSchema = { parse: (input: TInput) => TOutput; @@ -1907,14 +1950,6 @@ export const Progress: { // @public (undocumented) export type ProgressProps = {}; -// @public -export type ResolvedExtensionInput = - TExtensionInput['extensionData'] extends Array - ? { - node: AppNode; - } & ExtensionDataContainer - : never; - // @public export type ResolvedExtensionInputs< TInputs extends { diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index 2251bfafb7..dfd7f1e2bf 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint'; +// These exports are now available from the main entry point and are +// re-exported here only for backwards compatibility. +export { + PluginWrapperBlueprint, + type PluginWrapperDefinition, +} from './blueprints/PluginWrapperBlueprint'; export { type PluginWrapperApi, pluginWrapperApiRef, diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index a4b66d6128..8d9b224b1f 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -15,21 +15,23 @@ */ import { ComponentType, ReactNode } from 'react'; -import { createApiRef } from '@backstage/frontend-plugin-api'; +import { createApiRef } from '../system'; /** - * The Plugin Wrapper API is used to wrap plugin extensions with providers, - * plugins should generally use `ExtensionBoundary` instead. + * The Plugin Wrapper API allows plugins to wrap their extensions with + * providers. This API is only intended for internal use by the Backstage + * frontend system. To provide contexts to plugin components, use + * `ExtensionBoundary` instead. * - * @remarks - * - * This API is primarily intended for internal use by the Backstage frontend - * system, but can be used for advanced use-cases. If you do override it, be - * sure to include the default implementation as well. - * - * @alpha + * @public */ export type PluginWrapperApi = { + /** + * Returns the root wrapper that manages the global plugin state across + * plugin wrapper instances. + */ + getRootWrapper(): ComponentType<{ children: ReactNode }>; + /** * Returns a wrapper component for a specific plugin, or undefined if no * wrappers exist. Do not use this API directly, instead use @@ -43,8 +45,8 @@ export type PluginWrapperApi = { /** * The API reference of {@link PluginWrapperApi}. * - * @alpha + * @public */ export const pluginWrapperApiRef = createApiRef({ - id: 'core.plugin-wrapper.alpha', + id: 'core.plugin-wrapper', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts index 9a0ddcd958..76aa24e8f8 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-redeclare */ import { ApiRef, createApiRef } from '../system'; -import { IconComponent } from '../../icons/types'; +import { IconComponent, IconElement } from '../../icons/types'; import { Observable } from '@backstage/types'; /** @@ -54,8 +54,13 @@ export type AuthProviderInfo = { /** * Icon for the auth provider. + * + * @remarks + * + * Accepts either an `IconElement` (e.g. ``) or an `IconComponent` + * (e.g. `MyIcon`). Prefer passing `IconElement`. */ - icon: IconComponent; + icon: IconComponent | IconElement; /** * Optional user friendly messaage to display for the auth provider. diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 06d96a50a3..582eacf345 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -50,3 +50,4 @@ export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; export * from './PluginHeaderActionsApi'; +export * from './PluginWrapperApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx index f6f673bd93..5119ae1617 100644 --- a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx @@ -21,14 +21,42 @@ import { createExtensionDataRef, } from '../wiring'; +/** + * Defines the structure of a plugin wrapper, optionally including a shared + * hook value. + * + * @remarks + * + * When `useWrapperValue` is provided, the hook is called in a single location + * in the app and the resulting value is forwarded as the `value` prop to the + * component. The hook obeys the rules of React hooks and is not called until a + * component from the plugin is rendered. + * + * @public + */ +export type PluginWrapperDefinition = { + /** + * Creates a shared value that is forwarded as the `value` prop to the + * component. + * + * @remarks + * + * This function obeys the rules of React hooks and is only invoked in a + * single location in the app. Note that the hook will not be called until a + * component from the plugin is rendered. + */ + useWrapperValue?: () => TValue; + component: ComponentType<{ children: ReactNode; value: TValue }>; +}; + const wrapperDataRef = createExtensionDataRef< - () => Promise<{ component: ComponentType<{ children: ReactNode }> }> + () => Promise >().with({ id: 'core.plugin-wrapper.loader' }); /** * Creates extensions that wrap plugin extensions with providers. * - * @alpha + * @public */ export const PluginWrapperBlueprint = createExtensionBlueprint({ kind: 'plugin-wrapper', @@ -37,12 +65,12 @@ export const PluginWrapperBlueprint = createExtensionBlueprint({ dataRefs: { wrapper: wrapperDataRef, }, - defineParams(params: { - loader: () => Promise<{ - component: ComponentType<{ children: ReactNode }>; - }>; + defineParams(params: { + loader: () => Promise>; }) { - return createExtensionBlueprintParams(params); + return createExtensionBlueprintParams( + params as { loader: () => Promise }, + ); }, *factory(params) { yield wrapperDataRef(params.loader); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index d413776419..a571f6ef73 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -24,3 +24,7 @@ export { NavItemBlueprint } from './NavItemBlueprint'; export { PageBlueprint } from './PageBlueprint'; export { SubPageBlueprint } from './SubPageBlueprint'; export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint'; +export { + PluginWrapperBlueprint, + type PluginWrapperDefinition, +} from './PluginWrapperBlueprint'; diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index 1215c7576f..d05e7c366f 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -144,6 +144,10 @@ describe('ExtensionBoundary', () => { }; const pluginWrapperApi: PluginWrapperApi = { + getRootWrapper: + () => + ({ children }: { children: ReactNode }) => + <>{children}, getPluginWrapper: jest.fn((pluginId: string) => { if (pluginId === 'app') { return WrapperComponent; @@ -180,6 +184,10 @@ describe('ExtensionBoundary', () => { }; const pluginWrapperApi: PluginWrapperApi = { + getRootWrapper: + () => + ({ children }: { children: ReactNode }) => + <>{children}, getPluginWrapper: jest.fn((pluginId: string) => { if (pluginId === 'app') { return ThrowingWrapper; diff --git a/packages/frontend-plugin-api/src/icons/types.ts b/packages/frontend-plugin-api/src/icons/types.ts index 1a45bcd8fc..180763ad09 100644 --- a/packages/frontend-plugin-api/src/icons/types.ts +++ b/packages/frontend-plugin-api/src/icons/types.ts @@ -42,7 +42,14 @@ export type IconComponent = ComponentType<{ * * @remarks * - * Icons should be exactly 24x24 pixels in size. + * Icon elements should behave like rendering a plain icon directly, for example + * from `@remixicon/react`, and are expected to be sized by the surrounding UI. + * Icons should be exactly 24x24 pixels in size by default. + * + * Using icons from `@remixicon/react` is preferred. Using icons from + * `@material-ui/icons` or `AppIcon` and its variants from + * `@backstage/core-components` is supported while migrating, but deprecated. + * When using those icons, you must set `fontSize="inherit"` on the element. * * @public */ diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 4d3eeece2c..329205ae76 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -43,11 +43,8 @@ import { FrontendModule } from './createFrontendModule'; */ export const ctxParamsSymbol = Symbol('params'); -/** - * Convert a single extension input into a matching resolved input. - * @public - */ -export type ResolvedExtensionInput = +/** @ignore */ +type ResolvedExtensionInput = TExtensionInput['extensionData'] extends Array ? { node: AppNode; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts index 27b12deb84..8ae176d249 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts @@ -33,7 +33,7 @@ export type ExtensionDataRef< readonly config: TConfig; }; -/** @public */ +/** @ignore */ export type ExtensionDataRefToValue = TDataRef extends ExtensionDataRef ? ExtensionDataValue diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 044bf03b67..d3acef46b6 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,7 +22,6 @@ export { type ExtensionDefinitionParameters, type CreateExtensionOptions, type OverridableExtensionDefinition, - type ResolvedExtensionInput, type ResolvedExtensionInputs, } from './createExtension'; export { @@ -32,7 +31,6 @@ export { export { createExtensionDataRef, type ExtensionDataRef, - type ExtensionDataRefToValue, type ExtensionDataValue, type ConfigurableExtensionDataRef, } from './createExtensionDataRef'; diff --git a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts index b9f239842d..9a122c8288 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts @@ -16,7 +16,6 @@ import { AppNode } from '../apis'; import { Expand } from '@backstage/types'; -import { ResolvedExtensionInput } from './createExtension'; import { createExtensionDataContainer } from '@internal/frontend'; import { ExtensionDataRefToValue, @@ -124,7 +123,7 @@ export function resolveInputOverrides( ); } newInputs[name] = Object.assign(providedContainer, { - node: (originalInput as ResolvedExtensionInput).node, + node: (originalInput as { node: AppNode }).node, }) as any; } } else { @@ -158,7 +157,7 @@ export function resolveInputOverrides( declaredInput.extensionData, ); return Object.assign(providedContainer, { - node: (originalInput[i] as ResolvedExtensionInput).node, + node: (originalInput[i] as { node: AppNode }).node, }) as any; }); } else if (withNodesCount === providedData.length) { diff --git a/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts b/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts index 787a2cb3f4..6091237fae 100644 --- a/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts +++ b/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts @@ -174,6 +174,7 @@ export async function runApiExtraction({ filename => // https://regex101.com/r/QDZIV0/2 filename !== 'knip-report.md' && + !filename.startsWith('cli-report') && !filename.endsWith('.sql.md') && // this has to temporarily match all old api report formats filename.match(/^.*?(api-)?report(-[^.-]+)?(.*?)\.md$/), diff --git a/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts b/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts index 973c7b8acb..6fdb890100 100644 --- a/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts +++ b/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts @@ -54,9 +54,10 @@ export async function categorizePackageDirs(packageDirs: string[]) { if (pkgJson?.backstage?.inline) { return; } - if (role === 'cli') { + if (role === 'cli' || role === 'cli-module') { cliPackageDirs.push(dir); - } else if (role !== 'frontend' && role !== 'backend') { + } + if (role !== 'cli' && role !== 'frontend' && role !== 'backend') { tsPackageDirs.push(dir); } } diff --git a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts index ae49c95f39..ce207489a7 100644 --- a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts +++ b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts @@ -132,7 +132,12 @@ export async function runCliExtraction({ const pkgJson = await fs.readJson(resolvePath(fullDir, 'package.json')); if (!pkgJson.bin) { - throw new Error(`CLI Package in ${packageDir} has no bin field`); + if (pkgJson.backstage?.role === 'cli') { + throw new Error( + `CLI package ${pkgJson.name} is missing a "bin" field in its package.json`, + ); + } + continue; } const models = new Array(); diff --git a/packages/ui/package.json b/packages/ui/package.json index 27ad892ea8..a1df4bcf8a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -60,7 +60,7 @@ "@types/use-sync-external-store": "^0.0.6", "eslint-plugin-storybook": "^10.3.0-alpha.1", "glob": "^11.0.1", - "globals": "^15.11.0", + "globals": "^17.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", "react-router-dom": "^6.30.2", diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 12159d7818..406ac5316e 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -766,7 +766,7 @@ export const Cell: { displayName: string; }; -// @public (undocumented) +// @public export type CellOwnProps = { className?: string; }; @@ -774,7 +774,7 @@ export type CellOwnProps = { // @public (undocumented) export const CellProfile: (props: CellProfileProps) => JSX_2.Element; -// @public (undocumented) +// @public export type CellProfileOwnProps = { src?: string; name?: string; @@ -784,12 +784,12 @@ export type CellProfileOwnProps = { className?: string; }; -// @public (undocumented) +// @public export interface CellProfileProps extends CellProfileOwnProps, Omit {} -// @public (undocumented) +// @public export interface CellProps extends CellOwnProps, Omit {} @@ -800,7 +800,7 @@ export const CellText: { displayName: string; }; -// @public (undocumented) +// @public export type CellTextOwnProps = { title: string; description?: string; @@ -810,7 +810,7 @@ export type CellTextOwnProps = { className?: string; }; -// @public (undocumented) +// @public export interface CellTextProps extends CellTextOwnProps, Omit {} @@ -830,12 +830,6 @@ export const CheckboxDefinition: { readonly indicator: 'bui-CheckboxIndicator'; }; readonly propDefs: { - readonly selected: { - readonly dataAttribute: true; - }; - readonly indeterminate: { - readonly dataAttribute: true; - }; readonly children: {}; readonly className: {}; }; @@ -843,8 +837,6 @@ export const CheckboxDefinition: { // @public (undocumented) export type CheckboxOwnProps = { - selected?: boolean; - indeterminate?: boolean; children: React.ReactNode; className?: string; }; @@ -857,9 +849,8 @@ export interface CheckboxProps // @public (undocumented) export const Column: (props: ColumnProps) => JSX_2.Element; -// @public (undocumented) +// @public export interface ColumnConfig { - // (undocumented) cell: (item: T) => ReactElement; // (undocumented) defaultWidth?: ColumnSize | null; @@ -1417,11 +1408,11 @@ export const HeaderDefinition: { readonly [key: string]: string; }; readonly classNames: { - readonly root: 'bui-HeaderPage'; - readonly content: 'bui-HeaderPageContent'; - readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs'; - readonly tabsWrapper: 'bui-HeaderPageTabsWrapper'; - readonly controls: 'bui-HeaderPageControls'; + readonly root: 'bui-Header'; + readonly content: 'bui-HeaderContent'; + readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly tabsWrapper: 'bui-HeaderTabsWrapper'; + readonly controls: 'bui-HeaderControls'; }; readonly propDefs: { readonly title: {}; @@ -1458,11 +1449,11 @@ export const HeaderPageDefinition: { readonly [key: string]: string; }; readonly classNames: { - readonly root: 'bui-HeaderPage'; - readonly content: 'bui-HeaderPageContent'; - readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs'; - readonly tabsWrapper: 'bui-HeaderPageTabsWrapper'; - readonly controls: 'bui-HeaderPageControls'; + readonly root: 'bui-Header'; + readonly content: 'bui-HeaderContent'; + readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; + readonly tabsWrapper: 'bui-HeaderTabsWrapper'; + readonly controls: 'bui-HeaderControls'; }; readonly propDefs: { readonly title: {}; @@ -2076,12 +2067,91 @@ export interface RowProps extends RowOwnProps, Omit, keyof RowOwnProps> {} -// @public (undocumented) +// @public export type RowRenderFn = (params: { item: T; index: number; }) => ReactNode; +// @public (undocumented) +export function SearchAutocomplete( + props: SearchAutocompleteProps, +): JSX_2.Element; + +// @public +export const SearchAutocompleteDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly classNames: { + readonly root: 'bui-SearchAutocomplete'; + readonly searchField: 'bui-SearchAutocompleteSearchField'; + readonly searchFieldInput: 'bui-SearchAutocompleteInput'; + readonly searchFieldClear: 'bui-SearchAutocompleteClear'; + readonly popover: 'bui-SearchAutocompletePopover'; + readonly inner: 'bui-SearchAutocompleteInner'; + readonly listBox: 'bui-SearchAutocompleteListBox'; + readonly loadingState: 'bui-SearchAutocompleteLoadingState'; + readonly emptyState: 'bui-SearchAutocompleteEmptyState'; + }; + readonly propDefs: { + readonly 'aria-label': {}; + readonly 'aria-labelledby': {}; + readonly size: { + readonly dataAttribute: true; + readonly default: 'small'; + }; + readonly placeholder: { + readonly default: 'Search'; + }; + readonly inputValue: {}; + readonly onInputChange: {}; + readonly popoverWidth: {}; + readonly popoverPlacement: {}; + readonly children: {}; + readonly isLoading: {}; + readonly defaultOpen: {}; + readonly className: {}; + readonly style: {}; + }; +}; + +// @public (undocumented) +export function SearchAutocompleteItem( + props: SearchAutocompleteItemProps, +): JSX_2.Element; + +// @public (undocumented) +export type SearchAutocompleteItemOwnProps = { + children: ReactNode; + className?: string; +}; + +// @public (undocumented) +export interface SearchAutocompleteItemProps + extends SearchAutocompleteItemOwnProps, + Omit {} + +// @public (undocumented) +export type SearchAutocompleteOwnProps = { + inputValue?: string; + onInputChange?: (value: string) => void; + size?: 'small' | 'medium' | Partial>; + 'aria-label'?: string; + 'aria-labelledby'?: string; + placeholder?: string; + popoverWidth?: string; + popoverPlacement?: PopoverProps_2['placement']; + children?: ReactNode; + isLoading?: boolean; + defaultOpen?: boolean; + className?: string; + style?: React.CSSProperties; +}; + +// @public (undocumented) +export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {} + // @public (undocumented) export const SearchField: ForwardRefExoticComponent< SearchFieldProps & RefAttributes @@ -2340,6 +2410,9 @@ export const TableDefinition: { readonly stale: { readonly dataAttribute: true; }; + readonly loading: { + readonly dataAttribute: true; + }; }; }; @@ -2449,6 +2522,8 @@ export interface TableProps { sort?: SortState; // (undocumented) style?: React.CSSProperties; + // (undocumented) + virtualized?: VirtualizedProp; } // @public (undocumented) @@ -2457,6 +2532,7 @@ export const TableRoot: (props: TableRootProps) => JSX_2.Element; // @public (undocumented) export type TableRootOwnProps = { stale?: boolean; + loading?: boolean; }; // @public (undocumented) @@ -2971,6 +3047,16 @@ export interface UtilityProps extends SpaceProps { rowSpan?: Responsive; } +// @public (undocumented) +export type VirtualizedProp = + | boolean + | { + rowHeight: number; + } + | { + estimatedRowHeight: number; + }; + // @public export const VisuallyHidden: (props: VisuallyHiddenProps) => JSX_2.Element; diff --git a/packages/ui/src/analytics/index.ts b/packages/ui/src/analytics/index.ts index a9b073afa7..de42ed3181 100644 --- a/packages/ui/src/analytics/index.ts +++ b/packages/ui/src/analytics/index.ts @@ -15,8 +15,6 @@ */ export { useAnalytics } from './useAnalytics'; -export { BUIProvider } from './BUIProvider'; -export type { BUIProviderProps } from './BUIProvider'; export { getNodeText } from './getNodeText'; export type { AnalyticsTracker, diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx index 4b9b9b747c..5be8a7edca 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx @@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite'; import { ButtonLink } from './ButtonLink'; import { Flex } from '../Flex'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react'; const meta = preview.meta({ @@ -27,7 +28,9 @@ const meta = preview.meta({ decorators: [ (Story: StoryFn) => ( - + + + ), ], diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index 96f477a4e8..4d6906f32f 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -19,7 +19,6 @@ import { Link as RALink } from 'react-aria-components'; import type { ButtonLinkProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { ButtonLinkDefinition } from './definition'; -import { InternalLinkProvider } from '../InternalLinkProvider'; import { getNodeText } from '../../analytics/getNodeText'; /** @public */ @@ -43,21 +42,19 @@ export const ButtonLink = forwardRef( }; return ( - - - - {iconStart} - {children} - {iconEnd} - - - + + + {iconStart} + {children} + {iconEnd} + + ); }, ); diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index 70a2438a49..aecca09512 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -77,7 +77,7 @@ export const Card = forwardRef((props, ref) => { triggerRef.current.dispatchEvent( new MouseEvent('click', { - bubbles: false, + bubbles: true, cancelable: true, ctrlKey: e.ctrlKey, metaKey: e.metaKey, diff --git a/packages/ui/src/components/Checkbox/Checkbox.module.css b/packages/ui/src/components/Checkbox/Checkbox.module.css index deadc96358..54705d95e9 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.module.css +++ b/packages/ui/src/components/Checkbox/Checkbox.module.css @@ -20,7 +20,7 @@ .bui-Checkbox { display: flex; flex-direction: row; - align-items: center; + align-items: flex-start; gap: var(--bui-space-2); font-size: var(--bui-font-size-3); font-family: var(--bui-font-regular); diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index e8f7859785..94811d5cb6 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -16,6 +16,8 @@ import preview from '../../../../../.storybook/preview'; import { Checkbox } from './Checkbox'; import { Flex } from '../Flex'; +import { Link } from '../Link'; +import { MemoryRouter } from 'react-router-dom'; const meta = preview.meta({ title: 'Backstage UI/Checkbox', @@ -28,6 +30,12 @@ export const Default = meta.story({ }, }); +export const Selected = Default.extend({ + args: { + isSelected: true, + }, +}); + export const Indeterminate = meta.story({ args: { children: 'Select all', @@ -35,6 +43,25 @@ export const Indeterminate = meta.story({ }, }); +export const WithLongText = Default.extend({ + args: { + children: ( + <> + I agree to receive future communication from Spotify. You may + unsubscribe from these communications at any time. Please review our{' '} + Privacy Policy + + ), + }, + decorators: [ + Story => ( + + + + ), + ], +}); + export const AllVariants = meta.story({ ...Default.input, render: () => ( diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index a825ccfb8a..d7b0bd6f76 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -46,7 +46,7 @@ export const Checkbox = forwardRef( )} - {children} +
{children}
)} diff --git a/packages/ui/src/components/Checkbox/definition.ts b/packages/ui/src/components/Checkbox/definition.ts index 5aa2473eac..b6666924ee 100644 --- a/packages/ui/src/components/Checkbox/definition.ts +++ b/packages/ui/src/components/Checkbox/definition.ts @@ -29,8 +29,6 @@ export const CheckboxDefinition = defineComponent()({ indicator: 'bui-CheckboxIndicator', }, propDefs: { - selected: { dataAttribute: true }, - indeterminate: { dataAttribute: true }, children: {}, className: {}, }, diff --git a/packages/ui/src/components/Checkbox/types.ts b/packages/ui/src/components/Checkbox/types.ts index 8507df1e5b..d9b220eb2a 100644 --- a/packages/ui/src/components/Checkbox/types.ts +++ b/packages/ui/src/components/Checkbox/types.ts @@ -17,8 +17,6 @@ import type { CheckboxProps as RACheckboxProps } from 'react-aria-components'; /** @public */ export type CheckboxOwnProps = { - selected?: boolean; - indeterminate?: boolean; children: React.ReactNode; className?: string; }; diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index b93665525f..1ebf52ccf3 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -21,7 +21,7 @@ max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; - padding-bottom: var(--bui-space-8); + margin-bottom: var(--bui-space-8); } @media (min-width: 640px) { diff --git a/packages/ui/src/components/Dialog/Dialog.module.css b/packages/ui/src/components/Dialog/Dialog.module.css index da82509134..194128ec4d 100644 --- a/packages/ui/src/components/Dialog/Dialog.module.css +++ b/packages/ui/src/components/Dialog/Dialog.module.css @@ -50,9 +50,11 @@ border: 1px solid var(--bui-border-1); color: var(--bui-fg-primary); position: relative; + display: flex; + flex-direction: column; width: min(var(--bui-dialog-min-width, 400px), calc(100vw - 3rem)); max-width: calc(100vw - 3rem); - height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem)); + height: var(--bui-dialog-height, auto); max-height: calc(100vh - 3rem); outline: none; } @@ -61,7 +63,9 @@ display: flex; flex-direction: column; border-radius: var(--dialog-border-radius); - height: 100%; + flex: 1; + min-height: 0; + overflow: hidden; } /* Dialog entering animation */ @@ -102,6 +106,7 @@ .bui-DialogBody { padding: var(--bui-space-3); flex: 1; + min-height: 0; overflow-y: auto; } diff --git a/packages/ui/src/components/Dialog/Dialog.stories.tsx b/packages/ui/src/components/Dialog/Dialog.stories.tsx index 9084bfa7e4..8892cb2314 100644 --- a/packages/ui/src/components/Dialog/Dialog.stories.tsx +++ b/packages/ui/src/components/Dialog/Dialog.stories.tsx @@ -247,6 +247,39 @@ export const WithForm = meta.story({ ), }); +export const OverflowWithoutHeight = meta.story({ + args: { + defaultOpen: true, + }, + render: args => ( + + + + Overflow Without Height + + + {Array.from({ length: 20 }, (_, i) => ( + + Line {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. + + ))} + + + + + + + + + ), +}); + export const PreviewFixedWidthAndHeight = FixedWidth.extend({ args: { defaultOpen: undefined, diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx index fad5e10ea2..3a23ee27c9 100644 --- a/packages/ui/src/components/Dialog/Dialog.tsx +++ b/packages/ui/src/components/Dialog/Dialog.tsx @@ -67,11 +67,12 @@ export const Dialog = forwardRef, DialogProps>( style={{ ['--bui-dialog-min-width' as keyof React.CSSProperties]: typeof width === 'number' ? `${width}px` : width || '400px', - ['--bui-dialog-min-height' as keyof React.CSSProperties]: height - ? typeof height === 'number' - ? `${height}px` - : height - : 'auto', + ...(height + ? { + ['--bui-dialog-height' as keyof React.CSSProperties]: + typeof height === 'number' ? `${height}px` : height, + } + : {}), ...style, }} > diff --git a/packages/ui/src/components/FullPage/FullPage.stories.tsx b/packages/ui/src/components/FullPage/FullPage.stories.tsx index 05169ca8ea..6adfaa7efb 100644 --- a/packages/ui/src/components/FullPage/FullPage.stories.tsx +++ b/packages/ui/src/components/FullPage/FullPage.stories.tsx @@ -22,6 +22,7 @@ import { Container } from '../Container'; import { Text } from '../Text'; import type { HeaderTab } from '../PluginHeader/types'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/FullPage', @@ -33,7 +34,9 @@ const meta = preview.meta({ const withRouter = (Story: StoryFn) => ( - + + + ); diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 4a81c6dcb0..d74d3eeb50 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -17,32 +17,31 @@ @layer tokens, base, components, utilities; @layer components { - .bui-HeaderPage { + .bui-Header { display: flex; flex-direction: column; gap: var(--bui-space-1); margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); } - .bui-HeaderPageContent { + .bui-HeaderContent { display: flex; flex-direction: row; justify-content: space-between; } - .bui-HeaderPageTabsWrapper { + .bui-HeaderTabsWrapper { margin-left: -8px; } - .bui-HeaderPageControls { + .bui-HeaderControls { display: flex; flex-direction: row; align-items: center; gap: var(--bui-space-2); } - .bui-HeaderPageBreadcrumbs { + .bui-HeaderBreadcrumbs { display: flex; flex-direction: row; align-items: center; diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index e605f2ed58..0adfb43d55 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite'; import { Header } from './Header'; import type { HeaderTab } from '../PluginHeader/types'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { Button, Container, @@ -88,7 +89,9 @@ const menuItems = [ const withRouter = (Story: StoryFn) => ( - + + + ); @@ -239,32 +242,34 @@ export const WithTabsMatchingStrategies = meta.story({ }, render: args => ( -
- - - Current URL: /mentorship/events - -
- - Notice how the "Mentorship" tab is active even though we're on a - nested route. This is because it uses{' '} - matchStrategy="prefix". - -
- - • Home: exact matching (default) - not active - - - • Mentorship: prefix matching - IS active (URL starts - with /mentorship) - - - • Catalog: prefix matching - not active - - - • Settings: exact matching (default) - not active - -
+ +
+ + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL + starts with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+ ), }); @@ -292,18 +297,20 @@ export const WithTabsExactMatching = meta.story({ }, render: args => ( -
- - - Current URL: /mentorship/events - -
- - With default exact matching, only the "Events" tab is active because - it exactly matches the current URL. The "Mentorship" tab is not active - even though the URL is under /mentorship. - -
+ +
+ + + Current URL: /mentorship/events + +
+ + With default exact matching, only the "Events" tab is active because + it exactly matches the current URL. The "Mentorship" tab is not + active even though the URL is under /mentorship. + +
+ ), }); @@ -334,33 +341,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({ }, render: args => ( -
- - - Current URL: /catalog/users/john/details - -
- - Active tab is Users because: - -
    -
  • - Catalog: Matches since URL starts with /catalog -
  • -
  • - Users: Is active since URL starts with - /catalog/users, and is more specific (has more url segments) than - "Catalog" -
  • -
  • - Components: not active (URL doesn't start with - /catalog/components) -
  • -
- - This demonstrates how prefix matching works with deeply nested routes. - -
+ +
+ + + Current URL: /catalog/users/john/details + +
+ + Active tab is Users because: + +
    +
  • + Catalog: Matches since URL starts with /catalog +
  • +
  • + Users: Is active since URL starts with + /catalog/users, and is more specific (has more url segments) than + "Catalog" +
  • +
  • + Components: not active (URL doesn't start with + /catalog/components) +
  • +
+ + This demonstrates how prefix matching works with deeply nested + routes. + +
+ ), }); diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 0eb3ad33a3..66fccf2f49 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -25,11 +25,11 @@ import styles from './Header.module.css'; export const HeaderDefinition = defineComponent()({ styles, classNames: { - root: 'bui-HeaderPage', - content: 'bui-HeaderPageContent', - breadcrumbs: 'bui-HeaderPageBreadcrumbs', - tabsWrapper: 'bui-HeaderPageTabsWrapper', - controls: 'bui-HeaderPageControls', + root: 'bui-Header', + content: 'bui-HeaderContent', + breadcrumbs: 'bui-HeaderBreadcrumbs', + tabsWrapper: 'bui-HeaderTabsWrapper', + controls: 'bui-HeaderControls', }, propDefs: { title: {}, diff --git a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx b/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx deleted file mode 100644 index 3c8a2ac456..0000000000 --- a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { RouterProvider } from 'react-aria-components'; -import { useNavigate, useHref } from 'react-router-dom'; -import { isExternalLink } from '../../utils/isExternalLink'; - -/** - * Checks if an href is an internal link (not external and not empty). - * - * @internal - */ -export function isInternalLink(href: string | undefined): href is string { - return !!href && !isExternalLink(href); -} - -/** - * Context value type for routing registration. - * Used by container components to track children that need RouterProvider. - * - * @internal - */ -export type RoutingContextValue = { - register: () => () => void; -}; - -/** - * Wraps children in a RouterProvider for client-side navigation. - * Must be rendered within a React Router context. - * - * @internal - */ -export function RoutedContainer({ children }: { children: ReactNode }) { - const navigate = useNavigate(); - return ( - - {children} - - ); -} - -/** - * Hook for container components that need to conditionally provide routing. - * - * Usage: - * 1. Call this hook in the container component - * 2. Pass `contextValue` to a RoutingContextValue context provider - * 3. Children call `register()` via context when they have internal hrefs - * 4. If `hasRoutedChildren` is true, wrap content in RoutedContainer - * - * @internal - */ -export function useRoutingRegistration(): { - hasRoutedChildren: boolean; - contextValue: RoutingContextValue; -} { - const [count, setCount] = useState(0); - - const register = useCallback(() => { - setCount(c => c + 1); - return () => setCount(c => c - 1); - }, []); - - return { hasRoutedChildren: count > 0, contextValue: { register } }; -} - -/** - * Creates a routing registration context and provider for container components. - * - * Usage: - * ```tsx - * // At module level - * const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration(); - * - * // Container component wraps content with provider - * {content} - * - * // Child items register when they have internal hrefs - * useRoutingRegistrationEffect(href); - * ``` - * - * @internal - */ -export function createRoutingRegistration() { - const RoutingContext = createContext(null); - - function RoutingProvider({ children }: { children: ReactNode }) { - const { hasRoutedChildren, contextValue } = useRoutingRegistration(); - - const content = ( - - {children} - - ); - - if (hasRoutedChildren) { - return {content}; - } - - return content; - } - - function useRoutingRegistrationEffect(href: string | undefined) { - const routingCtx = useContext(RoutingContext); - const hasInternalHref = isInternalLink(href); - - useEffect(() => { - if (hasInternalHref && routingCtx) { - return routingCtx.register(); - } - return undefined; - }, [hasInternalHref, routingCtx]); - } - - return { RoutingContext, RoutingProvider, useRoutingRegistrationEffect }; -} - -/** - * Conditionally wraps children in a RouterProvider for internal link navigation. - * Only mounts the router hooks when `href` is an internal link, avoiding the - * requirement for a Router context when rendering components without internal hrefs. - * - * @internal - */ -export function InternalLinkProvider({ - href, - children, -}: { - href: string | undefined; - children: ReactNode; -}) { - if (!isInternalLink(href)) { - return <>{children}; - } - return {children}; -} diff --git a/packages/ui/src/components/Link/Link.stories.tsx b/packages/ui/src/components/Link/Link.stories.tsx index fafb5948d9..cf52cdde23 100644 --- a/packages/ui/src/components/Link/Link.stories.tsx +++ b/packages/ui/src/components/Link/Link.stories.tsx @@ -20,6 +20,7 @@ import { Link } from './Link'; import { Flex } from '../Flex'; import { Text } from '../Text'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/Link', @@ -30,7 +31,9 @@ const meta = preview.meta({ decorators: [ (Story: StoryFn) => ( - + + + ), ], diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index c0de9c3087..a4509576c9 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -19,7 +19,6 @@ import { useLink } from 'react-aria'; import type { LinkProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; import { LinkDefinition } from './definition'; -import { InternalLinkProvider } from '../InternalLinkProvider'; import { getNodeText } from '../../analytics/getNodeText'; const LinkInternal = forwardRef((props, ref) => { @@ -64,11 +63,7 @@ LinkInternal.displayName = 'LinkInternal'; /** @public */ export const Link = forwardRef((props, ref) => { - return ( - - - - ); + return ; }); Link.displayName = 'Link'; diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index 982055ade1..26164f3752 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -36,6 +36,7 @@ import { RiShareBoxLine, } from '@remixicon/react'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { useEffect, useState } from 'react'; const meta = preview.meta({ @@ -44,7 +45,9 @@ const meta = preview.meta({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 5ab55cf7d8..cb6028c45a 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -62,17 +62,11 @@ import { RiCheckLine, RiCloseCircleLine, } from '@remixicon/react'; -import { - isInternalLink, - createRoutingRegistration, -} from '../InternalLinkProvider'; +import { isInternalLink } from '../../utils/linkUtils'; import { getNodeText } from '../../analytics/getNodeText'; import { Box } from '../Box'; import { BgReset } from '../../hooks/useBg'; -const { RoutingProvider, useRoutingRegistrationEffect } = - createRoutingRegistration(); - // The height will be used for virtualized menus. It should match the size set in CSS for each menu item. const rowHeight = 32; @@ -110,26 +104,24 @@ export const Menu = (props: MenuProps) => { ); return ( - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - - - - + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + + + ); }; @@ -206,40 +198,38 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { ); return ( - - - - - - + + + + + + + + + + {virtualized ? ( + - - - - - - {virtualized ? ( - - {menuContent} - - ) : ( - menuContent - )} - - - - - + {menuContent} + + ) : ( + menuContent + )} + + + + ); }; @@ -318,8 +308,6 @@ export const MenuItem = (props: MenuItemProps) => { ); const { classes, iconStart, children, href } = ownProps; - useRoutingRegistrationEffect(href); - const handleAction = () => { if (href) { const text = diff --git a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx index af68ec9415..7ebd25a063 100644 --- a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx +++ b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx @@ -25,6 +25,7 @@ import { import { Button } from '../..'; import { useState, useEffect } from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/MenuAutocomplete', @@ -32,7 +33,9 @@ const meta = preview.meta({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx index e1c22fbd9e..f3e001c165 100644 --- a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx @@ -27,6 +27,7 @@ import { Button, Flex, Text } from '../..'; import { useEffect, useState } from 'react'; import { Selection } from 'react-aria-components'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/MenuAutocompleteListBox', @@ -34,7 +35,9 @@ const meta = preview.meta({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx index 2297598d0c..469a106280 100644 --- a/packages/ui/src/components/Menu/MenuListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx @@ -20,6 +20,7 @@ import { Button, Flex, Text } from '../..'; import { useEffect, useState } from 'react'; import { Selection } from 'react-aria-components'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/MenuListBox', @@ -27,7 +28,9 @@ const meta = preview.meta({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx index 151c48aab6..d0d3475dbb 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx @@ -29,6 +29,7 @@ import { MenuItem, } from '../../'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { RiHeartLine, RiEmotionHappyLine, @@ -47,7 +48,9 @@ const meta = preview.meta({ const withRouter = (Story: StoryFn) => ( - + + + ); @@ -336,16 +339,18 @@ export const WithMockedURLCampaigns = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /campaigns - - - Notice how the "Campaigns" tab is selected (highlighted) because it - matches the current path. - - + + + + + Current URL is mocked to be: /campaigns + + + Notice how the "Campaigns" tab is selected (highlighted) because it + matches the current path. + + + ), }); @@ -356,16 +361,18 @@ export const WithMockedURLIntegrations = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /integrations - - - Notice how the "Integrations" tab is selected (highlighted) because it - matches the current path. - - + + + + + Current URL is mocked to be: /integrations + + + Notice how the "Integrations" tab is selected (highlighted) because + it matches the current path. + + + ), }); @@ -376,20 +383,22 @@ export const WithMockedURLNoMatch = meta.story({ }, render: args => ( - - - - Current URL is mocked to be: /some-other-page - - - No tab is selected because the current path doesn't match any tab's - href. - - - Tabs without href (like "Overview", "Checks", "Tracks") fall back to - React Aria's internal state. - - + + + + + Current URL is mocked to be: /some-other-page + + + No tab is selected because the current path doesn't match any tab's + href. + + + Tabs without href (like "Overview", "Checks", "Tracks") fall back to + React Aria's internal state. + + + ), }); @@ -424,32 +433,34 @@ export const WithTabsMatchingStrategies = meta.story({ }, render: args => ( - - - - Current URL: /mentorship/events - -
- - Notice how the "Mentorship" tab is active even though we're on a - nested route. This is because it uses{' '} - matchStrategy="prefix". - -
- - • Home: exact matching (default) - not active - - - • Mentorship: prefix matching - IS active (URL starts - with /mentorship) - - - • Catalog: prefix matching - not active - - - • Settings: exact matching (default) - not active - -
+ + + + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL + starts with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+
), }); @@ -477,18 +488,20 @@ export const WithTabsExactMatching = meta.story({ }, render: args => ( - - - - Current URL: /mentorship/events - -
- - With default exact matching, only the "Events" tab is active because - it exactly matches the current URL. The "Mentorship" tab is not active - even though the URL is under /mentorship. - -
+ + + + + Current URL: /mentorship/events + +
+ + With default exact matching, only the "Events" tab is active because + it exactly matches the current URL. The "Mentorship" tab is not + active even though the URL is under /mentorship. + +
+
), }); @@ -519,33 +532,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({ }, render: args => ( - - - - Current URL: /catalog/users/john/details - -
- - Active tab is Users because: - -
    -
  • - Catalog: Matches since URL starts with /catalog -
  • -
  • - Users: Is active since URL starts with - /catalog/users, and is more specific (has more url segments) than - "Catalog" -
  • -
  • - Components: not active (URL doesn't start with - /catalog/components) -
  • -
- - This demonstrates how prefix matching works with deeply nested routes. - -
+ + + + + Current URL: /catalog/users/john/details + +
+ + Active tab is Users because: + +
    +
  • + Catalog: Matches since URL starts with /catalog +
  • +
  • + Users: Is active since URL starts with + /catalog/users, and is more specific (has more url segments) than + "Catalog" +
  • +
  • + Components: not active (URL doesn't start with + /catalog/components) +
  • +
+ + This demonstrates how prefix matching works with deeply nested + routes. + +
+
), }); diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx index ac5f04a011..d153a3af18 100644 --- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx +++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx @@ -19,7 +19,7 @@ import { Tabs, TabList, Tab } from '../Tabs'; import { useDefinition } from '../../hooks/useDefinition'; import { PluginHeaderDefinition } from './definition'; import { type NavigateOptions } from 'react-router-dom'; -import { useRef } from 'react'; +import { Children, useMemo, useRef } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; import { Box } from '../Box'; import { Link } from 'react-aria-components'; @@ -55,35 +55,70 @@ export const PluginHeader = (props: PluginHeaderProps) => { const toolbarWrapperRef = useRef(null); const toolbarContentRef = useRef(null); const toolbarControlsRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastAppliedHeightRef = useRef(undefined); + + const actionChildren = useMemo(() => { + return Children.toArray(customActions); + }, [customActions]); useIsomorphicLayoutEffect(() => { const el = headerRef.current; - if (!el) return undefined; + if (!el) { + return undefined; + } - const updateHeight = () => { - const height = el.offsetHeight; + const cancelScheduledUpdate = () => { + if (animationFrameRef.current === undefined) { + return; + } + + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = undefined; + }; + + const applyHeight = (height: number) => { + if (lastAppliedHeightRef.current === height) { + return; + } + + lastAppliedHeightRef.current = height; document.documentElement.style.setProperty( '--bui-header-height', `${height}px`, ); }; - // Set height once immediately - updateHeight(); + const scheduleHeightUpdate = () => { + cancelScheduledUpdate(); + animationFrameRef.current = requestAnimationFrame(() => { + animationFrameRef.current = undefined; + applyHeight(el.offsetHeight); + }); + }; + + // Set height once immediately so the initial layout is correct. + applyHeight(el.offsetHeight); // Observe for resize changes if ResizeObserver is available // (not present in Jest/jsdom by default) if (typeof ResizeObserver === 'undefined') { return () => { + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; } - const observer = new ResizeObserver(updateHeight); + const observer = new ResizeObserver(() => { + scheduleHeightUpdate(); + }); observer.observe(el); return () => { observer.disconnect(); + cancelScheduledUpdate(); + lastAppliedHeightRef.current = undefined; document.documentElement.style.removeProperty('--bui-header-height'); }; }, []); @@ -111,7 +146,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
- {customActions} + {actionChildren}
diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css new file mode 100644 index 0000000000..ca6de73402 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css @@ -0,0 +1,214 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-SearchAutocomplete { + display: flex; + align-items: center; + border-radius: var(--bui-radius-2); + background-color: var(--bui-bg-neutral-1); + transition: box-shadow 0.2s ease-in-out; + font-family: var(--bui-font-regular); + + &:has([data-focused]) { + box-shadow: inset 0 0 0 1px var(--bui-ring); + } + + &[data-size='small'] { + --search-autocomplete-item-height: 2rem; + height: 2rem; + } + + &[data-size='medium'] { + --search-autocomplete-item-height: 2.5rem; + height: 2.5rem; + } + } + + .bui-SearchAutocompleteSearchField { + display: flex; + align-items: center; + flex: 1; + + & > div { + display: flex; + align-items: center; + flex: 1; + } + + & svg { + flex: 0 0 auto; + color: var(--bui-fg-primary); + margin-inline-start: var(--bui-space-2); + + .bui-SearchAutocomplete[data-size='small'] & { + width: 1rem; + height: 1rem; + } + + .bui-SearchAutocomplete[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } + } + } + + .bui-SearchAutocompleteInput { + flex: 1; + display: flex; + align-items: center; + padding: 0; + padding-inline: var(--bui-space-2); + border: none; + background-color: transparent; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + width: 100%; + height: 100%; + outline: none; + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &::placeholder { + color: var(--bui-fg-secondary); + } + } + + .bui-SearchAutocompleteClear { + flex: 0 0 auto; + display: grid; + place-content: center; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--bui-fg-secondary); + transition: color 0.2s ease-in-out; + width: var(--search-autocomplete-item-height); + height: var(--search-autocomplete-item-height); + + &:hover { + color: var(--bui-fg-primary); + } + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-SearchAutocompletePopover { + --menu-border-radius: var(--bui-radius-2); + display: flex; + flex-direction: column; + box-shadow: var(--bui-shadow); + border: 1px solid var(--bui-border-1); + border-radius: var(--menu-border-radius); + background: var(--bui-bg-app); + color: var(--bui-fg-primary); + outline: none; + transition: transform 200ms, opacity 200ms; + min-height: 0; + overflow: hidden; + + &[data-entering], + &[data-exiting] { + transform: var(--origin); + opacity: 0; + } + + &[data-placement='top'] { + --origin: translateY(8px); + } + + &[data-placement='bottom'] { + --origin: translateY(-8px); + } + } + + .bui-SearchAutocompleteInner { + border-radius: var(--menu-border-radius); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + .bui-SearchAutocompleteListBox { + max-height: inherit; + box-sizing: border-box; + overflow: auto; + min-width: 150px; + outline: none; + padding-block: var(--bui-space-1); + + &[data-stale] { + opacity: 0.6; + } + } + + .bui-SearchAutocompleteItem { + padding-inline: var(--bui-space-1); + display: block; + + &:focus-visible { + outline: none; + } + + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: -2px; + } + + &[data-focused] .bui-SearchAutocompleteItemContent, + &[data-hovered] .bui-SearchAutocompleteItemContent { + background: var(--bui-bg-neutral-2); + color: var(--bui-fg-primary); + } + } + + .bui-SearchAutocompleteItemContent { + display: flex; + flex-direction: column; + gap: var(--bui-space-1); + min-height: 2rem; + padding-inline: var(--bui-space-2); + padding-block: var(--bui-space-2); + border-radius: var(--bui-radius-2); + outline: none; + cursor: default; + color: var(--bui-fg-primary); + font-size: var(--bui-font-size-3); + } + + .bui-SearchAutocompleteLoadingState, + .bui-SearchAutocompleteEmptyState { + padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-2); + color: var(--bui-fg-secondary); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + } +} diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx new file mode 100644 index 0000000000..1a044a9da8 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx @@ -0,0 +1,445 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable no-restricted-syntax */ + +import preview from '../../../../../.storybook/preview'; +import { useState, useEffect } from 'react'; +import { + SearchAutocomplete, + SearchAutocompleteItem, +} from './SearchAutocomplete'; +import { PluginHeader } from '../PluginHeader'; +import { Flex } from '../Flex'; +import { Text } from '../Text'; +import { ButtonIcon } from '../ButtonIcon'; +import { RiCactusLine } from '@remixicon/react'; +import { MemoryRouter } from 'react-router-dom'; + +const meta = preview.meta({ + title: 'Backstage UI/SearchAutocomplete', + component: SearchAutocomplete, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium'], + }, + placeholder: { + control: 'text', + }, + popoverWidth: { + control: 'text', + }, + }, +}); + +const fruits = [ + { id: 'apple', name: 'Apple', description: 'A round fruit' }, + { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' }, + { id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' }, + { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' }, + { id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' }, + { id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' }, + { id: 'mango', name: 'Mango', description: 'A tropical stone fruit' }, + { id: 'orange', name: 'Orange', description: 'A citrus fruit' }, + { id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' }, + { + id: 'strawberry', + name: 'Strawberry', + description: 'A red fruit with seeds on its surface', + }, +]; + +export const WithItems = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); + }, +}); + +export const WithRichContent = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +export const WithAsyncItems = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!inputValue) { + setItems([]); + return undefined; + } + + setIsLoading(true); + const timeout = setTimeout(() => { + setItems( + fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ), + ); + setIsLoading(false); + }, 2000); + + return () => clearTimeout(timeout); + }, [inputValue]); + + return ( + + {items.map(item => ( + + {item.name} + + {item.description} + + + ))} + + ); + }, +}); + +export const WithSelection = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); + }, +}); + +export const Sizes = meta.story({ + args: { + 'aria-label': 'Search', + placeholder: 'Search...', + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + ); + }, +}); + +export const InHeader = meta.story({ + args: { + 'aria-label': 'Search', + placeholder: 'Search...', + }, + decorators: [ + Story => ( + + + + ), + ], + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + } + size="small" + variant="secondary" + /> + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + } + size="small" + variant="secondary" + /> + + } + /> + ); + }, +}); + +export const WithPopoverWidth = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + popoverWidth: '500px', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +export const OpenByDefault = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + defaultOpen: true, + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + const [inputValue, setInputValue] = useState('ap'); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +export const OpenWithLoading = meta.story({ + args: { + 'aria-label': 'Search fruits', + placeholder: 'Search fruits...', + defaultOpen: true, + isLoading: true, + inputValue: 'ap', + style: { maxWidth: '300px' }, + }, + render: function Render(args) { + return ( + {}}> + {fruits.slice(0, 3).map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx new file mode 100644 index 0000000000..d8c4640e26 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx @@ -0,0 +1,204 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Children, useRef } from 'react'; +import { useInteractOutside } from '@react-aria/interactions'; +import { + Autocomplete, + SearchField as RASearchField, + Input, + Button as RAButton, + Popover as RAPopover, + ListBox, + ListBoxItem, + OverlayTriggerStateContext, +} from 'react-aria-components'; +import { useOverlayTriggerState } from '@react-stately/overlays'; +import { RiSearch2Line, RiCloseCircleLine } from '@remixicon/react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { + SearchAutocompleteDefinition, + SearchAutocompleteItemDefinition, +} from './definition'; +import { Box } from '../Box'; +import { BgReset } from '../../hooks/useBg'; +import { VisuallyHidden } from '../VisuallyHidden'; + +import type { + SearchAutocompleteProps, + SearchAutocompleteItemProps, +} from './types'; + +const SearchAutocompleteEmptyState = () => { + const { ownProps } = useDefinition(SearchAutocompleteDefinition, {}); + return
No results found.
; +}; + +/** @public */ +export function SearchAutocomplete(props: SearchAutocompleteProps) { + const { ownProps, dataAttributes } = useDefinition( + SearchAutocompleteDefinition, + props, + ); + const { + classes, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + inputValue, + onInputChange, + placeholder, + popoverWidth, + popoverPlacement = 'bottom start', + children, + isLoading, + defaultOpen, + style, + } = ownProps; + + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const hasValue = !!inputValue; + const hasChildren = Children.count(children) > 0; + + let liveMessage = ''; + if (isLoading) { + liveMessage = 'Searching'; + } else if (hasValue && !hasChildren) { + liveMessage = 'No results found'; + } + const overlayState = useOverlayTriggerState({ defaultOpen }); + + // Close on interact outside — same pattern as ComboBox. + // isNonModal disables useOverlay's built-in useInteractOutside, + // so we add our own that filters out clicks on the trigger. + useInteractOutside({ + ref: popoverRef, + onInteractOutside: e => { + const target = e.target as Element; + if (triggerRef.current?.contains(target)) { + return; + } + overlayState.close(); + }, + isDisabled: !overlayState.isOpen, + }); + + return ( + + { + onInputChange?.(value); + if (value) { + overlayState.open(); + } else { + overlayState.close(); + } + }} + > + { + if (e.key === 'Enter' && !overlayState.isOpen && hasValue) { + e.preventDefault(); + overlayState.open(); + } + }} + > +
+ + + + + +
+
+ {/* isNonModal keeps the page interactive while the popover is open, + required for virtual focus (aria-activedescendant) to work correctly */} + + + + + isLoading ? ( +
Searching...
+ ) : ( + + ) + } + onAction={() => { + overlayState.close(); + }} + > + {children} +
+
+
+
+ + {liveMessage} + +
+
+ ); +} + +/** @public */ +export function SearchAutocompleteItem(props: SearchAutocompleteItemProps) { + const { ownProps, restProps } = useDefinition( + SearchAutocompleteItemDefinition, + props, + ); + const { classes, children } = ownProps; + + return ( + +
{children}
+
+ ); +} diff --git a/packages/ui/src/components/SearchAutocomplete/definition.ts b/packages/ui/src/components/SearchAutocomplete/definition.ts new file mode 100644 index 0000000000..4a2e47881d --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/definition.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineComponent } from '../../hooks/useDefinition'; +import type { + SearchAutocompleteOwnProps, + SearchAutocompleteItemOwnProps, +} from './types'; +import styles from './SearchAutocomplete.module.css'; + +/** + * Component definition for SearchAutocomplete. + * @public + */ +export const SearchAutocompleteDefinition = + defineComponent()({ + styles, + classNames: { + root: 'bui-SearchAutocomplete', + searchField: 'bui-SearchAutocompleteSearchField', + searchFieldInput: 'bui-SearchAutocompleteInput', + searchFieldClear: 'bui-SearchAutocompleteClear', + popover: 'bui-SearchAutocompletePopover', + inner: 'bui-SearchAutocompleteInner', + listBox: 'bui-SearchAutocompleteListBox', + loadingState: 'bui-SearchAutocompleteLoadingState', + emptyState: 'bui-SearchAutocompleteEmptyState', + }, + propDefs: { + 'aria-label': {}, + 'aria-labelledby': {}, + size: { dataAttribute: true, default: 'small' }, + placeholder: { default: 'Search' }, + inputValue: {}, + onInputChange: {}, + popoverWidth: {}, + popoverPlacement: {}, + children: {}, + isLoading: {}, + defaultOpen: {}, + className: {}, + style: {}, + }, + }); + +/** @internal */ +export const SearchAutocompleteItemDefinition = + defineComponent()({ + styles, + classNames: { + root: 'bui-SearchAutocompleteItem', + itemContent: 'bui-SearchAutocompleteItemContent', + }, + propDefs: { + children: {}, + className: {}, + }, + }); diff --git a/packages/ui/src/components/SearchAutocomplete/index.ts b/packages/ui/src/components/SearchAutocomplete/index.ts new file mode 100644 index 0000000000..8e15383008 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + SearchAutocomplete, + SearchAutocompleteItem, +} from './SearchAutocomplete'; +export { SearchAutocompleteDefinition } from './definition'; +export type { + SearchAutocompleteProps, + SearchAutocompleteItemProps, + SearchAutocompleteOwnProps, + SearchAutocompleteItemOwnProps, +} from './types'; diff --git a/packages/ui/src/components/SearchAutocomplete/types.ts b/packages/ui/src/components/SearchAutocomplete/types.ts new file mode 100644 index 0000000000..a5920d370c --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/types.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + ListBoxItemProps as AriaListBoxItemProps, + PopoverProps as AriaPopoverProps, +} from 'react-aria-components'; +import type { ReactNode } from 'react'; +import type { Breakpoint } from '../../types'; + +/** @public */ +export type SearchAutocompleteOwnProps = { + /** + * The current value of the search input (controlled). + */ + inputValue?: string; + + /** + * Handler called when the search input value changes. + */ + onInputChange?: (value: string) => void; + + /** + * The size of the search input. + * @defaultValue 'small' + */ + size?: 'small' | 'medium' | Partial>; + + /** + * Accessible label for the search input. + */ + 'aria-label'?: string; + + /** + * ID of the element that labels the search input. + */ + 'aria-labelledby'?: string; + + /** + * The placeholder text for the search input. + */ + placeholder?: string; + + /** + * Width of the results popover. Accepts any CSS width value. + * When not set, the popover matches the input width. + */ + popoverWidth?: string; + + /** + * Placement of the results popover relative to the input. + * @defaultValue 'bottom start' + */ + popoverPlacement?: AriaPopoverProps['placement']; + + /** + * The result items to render inside the autocomplete. + */ + children?: ReactNode; + + /** + * Whether results are currently loading. + * When true, displays a loading indicator and announces the loading state to screen readers. + */ + isLoading?: boolean; + + /** + * Whether the results popover is open by default. + */ + defaultOpen?: boolean; + + className?: string; + style?: React.CSSProperties; +}; + +/** @public */ +export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {} + +/** @public */ +export type SearchAutocompleteItemOwnProps = { + children: ReactNode; + className?: string; +}; + +/** @public */ +export interface SearchAutocompleteItemProps + extends SearchAutocompleteItemOwnProps, + Omit {} diff --git a/packages/ui/src/components/SearchField/SearchField.stories.tsx b/packages/ui/src/components/SearchField/SearchField.stories.tsx index 70784f3206..380b55e580 100644 --- a/packages/ui/src/components/SearchField/SearchField.stories.tsx +++ b/packages/ui/src/components/SearchField/SearchField.stories.tsx @@ -27,6 +27,7 @@ import { RiCactusLine, RiEBike2Line } from '@remixicon/react'; import { Button } from '../Button'; import { PluginHeader } from '../PluginHeader'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; const meta = preview.meta({ title: 'Backstage UI/SearchField', @@ -188,7 +189,9 @@ export const InHeader = meta.story({ decorators: [ Story => ( - + + + ), ], @@ -225,7 +228,9 @@ export const StartCollapsedInHeader = meta.story({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 55cf235e56..62723db6c8 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -17,14 +17,31 @@ @layer tokens, base, components, utilities; @layer components { + .bui-TableWrapper { + display: flex; + flex-direction: column; + } + + .bui-TableResizableContainer { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + } + .bui-Table { width: 100%; caption-side: bottom; border-collapse: collapse; table-layout: fixed; transition: opacity 0.2s ease-in-out; + overflow: auto; + flex: 1; + min-height: 0; - &[data-stale='true'] { + &[data-stale='true'], + &[data-loading='true'] { opacity: 0.6; } } @@ -93,15 +110,15 @@ cursor: default; &:hover { - background-color: var(--bui-bg-tint-hover); + background-color: var(--bui-bg-neutral-1-hover); } &[data-selected] { - background-color: var(--bui-bg-tint-pressed); + background-color: var(--bui-bg-neutral-1-pressed); } &[data-pressed] { - background-color: var(--bui-bg-tint-pressed); + background-color: var(--bui-bg-neutral-1-pressed); } &[data-href], @@ -111,7 +128,7 @@ } &[data-disabled] { - background-color: var(--bui-bg-tint-disabled); + background-color: var(--bui-bg-neutral-1-disabled); cursor: not-allowed; } } diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx index 3da46b3eaa..9d4a15f601 100644 --- a/packages/ui/src/components/Table/components/Row.tsx +++ b/packages/ui/src/components/Table/components/Row.tsx @@ -24,8 +24,7 @@ import { Checkbox } from '../../Checkbox'; import { useDefinition } from '../../../hooks/useDefinition'; import { RowDefinition } from '../definition'; import type { RowProps } from '../types'; -import { isExternalLink } from '../../../utils/isExternalLink'; -import { InternalLinkProvider } from '../../InternalLinkProvider'; +import { isExternalLink } from '../../../utils/linkUtils'; import clsx from 'clsx'; import { Flex } from '../../Flex'; @@ -36,9 +35,26 @@ export function Row(props: RowProps) { props, ); const { classes, columns, children, href } = ownProps; - const hasInternalHref = !!href && !isExternalLink(href); + const isExternal = isExternalLink(href); + const hasInternalHref = !!href && !isExternal; + const hasExternalHref = !!href && isExternal; const hasInteraction = !!restProps.onAction || !!href; + // Derive the effective target, defaulting to _blank for external links. + const effectiveTarget = hasExternalHref ? '_blank' : restProps.target; + // Always include noopener noreferrer when target=_blank, merging any + // consumer-provided rel tokens to avoid reverse-tabnabbing risk. + const effectiveRel = + effectiveTarget === '_blank' + ? [ + ...new Set([ + 'noopener', + 'noreferrer', + ...(restProps.rel?.split(/\s+/).filter(Boolean) ?? []), + ]), + ].join(' ') + : restProps.rel; + const handlePress = hasInteraction ? () => { restProps.onAction?.(); @@ -68,16 +84,16 @@ export function Row(props: RowProps) { ); return ( - - - {content} - - + + {content} + ); } diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 523e5bd6c3..2376760b69 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -15,7 +15,14 @@ */ import { useId } from 'react-aria'; -import { type Key, ResizableTableContainer } from 'react-aria-components'; +import { + type Key, + ResizableTableContainer, + Virtualizer, +} from 'react-aria-components'; +import { TableLayout } from '@react-stately/layout'; +import { useDefinition } from '../../../hooks/useDefinition'; +import { TableWrapperDefinition } from '../definition'; import { TableRoot } from './TableRoot'; import { TableHeader } from './TableHeader'; import { TableBody } from './TableBody'; @@ -32,6 +39,7 @@ import type { import { useMemo } from 'react'; import { VisuallyHidden } from '../../VisuallyHidden'; import { Flex } from '../../Flex'; +import { TableBodySkeleton } from './TableBodySkeleton'; function isRowRenderFn( rowConfig: RowConfig | RowRenderFn | undefined, @@ -61,8 +69,13 @@ function useDisabledRows({ function useLiveRegionLabel( pagination: TablePaginationType, isStale: boolean, + isLoading: boolean, hasData: boolean, ): string { + if (isLoading) { + return 'Loading table data.'; + } + if (!hasData || pagination.type === 'none') { return ''; } @@ -99,7 +112,11 @@ export function Table({ emptyState, className, style, + virtualized, }: TableProps) { + const { + ownProps: { classes }, + } = useDefinition(TableWrapperDefinition, { className }); const liveRegionId = useId(); const visibleColumns = useMemo( @@ -115,17 +132,11 @@ export function Table({ onSelectionChange, } = selection || {}; - if (loading && !data) { - return ( -
- Loading... -
- ); - } + const isInitialLoading = loading && !data; if (error) { return ( -
+
Error: {error.message}
); @@ -134,6 +145,7 @@ export function Table({ const liveRegionLabel = useLiveRegionLabel( pagination, isStale, + isInitialLoading, data !== undefined, ); @@ -147,80 +159,105 @@ export function Table({ const wrapResizable = manualColumnSizing ? (elem: React.ReactNode) => ( - {elem} + + {elem} + ) : (elem: React.ReactNode) => <>{elem}; + const layoutOptions = + typeof virtualized === 'object' ? virtualized : undefined; + + const wrapVirtualized = (elem: React.ReactNode) => + virtualized ? ( + + {elem} + + ) : ( + elem + ); + return ( -
+
{liveRegionLabel} {wrapResizable( - - - {column => - column.header ? ( - column.header() - ) : ( - - {column.label} - - ) - } - - {emptyState} : undefined - } + wrapVirtualized( + - {item => { - const itemIndex = data?.indexOf(item) ?? -1; - - if (isRowRenderFn(rowConfig)) { - return rowConfig({ - item, - index: itemIndex, - }); + + {column => + column.header ? ( + column.header() + ) : ( + + {column.label} + + ) } + + {isInitialLoading ? ( + + ) : ( + {emptyState} : undefined + } + > + {item => { + const itemIndex = data?.indexOf(item) ?? -1; - return ( - rowConfig?.onClick?.(item) - : undefined + if (isRowRenderFn(rowConfig)) { + return rowConfig({ + item, + index: itemIndex, + }); } - > - {column => column.cell(item)} - - ); - }} - - , + + return ( + rowConfig?.onClick?.(item) + : undefined + } + > + {column => column.cell(item)} + + ); + }} + + )} + , + ), )} {pagination.type === 'page' && ( ({ + id: `skeleton-${i}`, +})); + +/** @internal */ +export function TableBodySkeleton({ + columns, +}: { + columns: readonly ColumnConfig[]; +}) { + return ( + + {item => { + const rowIndex = Number(item.id.split('-')[1]); + return ( + + {column => ( + + )} + + ); + }} + + ); +} diff --git a/packages/ui/src/components/Table/components/TableRoot.tsx b/packages/ui/src/components/Table/components/TableRoot.tsx index af96d1809e..223e9d648b 100644 --- a/packages/ui/src/components/Table/components/TableRoot.tsx +++ b/packages/ui/src/components/Table/components/TableRoot.tsx @@ -30,7 +30,7 @@ export const TableRoot = (props: TableRootProps) => { diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index eb41617b93..70234f26d0 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -27,6 +27,20 @@ import type { } from './types'; import styles from './Table.module.css'; +/** @internal */ +export const TableWrapperDefinition = defineComponent<{ + className?: string; +}>()({ + styles, + classNames: { + root: 'bui-TableWrapper', + resizableContainer: 'bui-TableResizableContainer', + }, + propDefs: { + className: {}, + }, +}); + /** * Component definition for Table * @public @@ -38,6 +52,7 @@ export const TableDefinition = defineComponent()({ }, propDefs: { stale: { dataAttribute: true }, + loading: { dataAttribute: true }, }, }); diff --git a/packages/ui/src/components/Table/hooks/useCompletePagination.ts b/packages/ui/src/components/Table/hooks/useCompletePagination.ts index 5aae9a36c1..d652f5203f 100644 --- a/packages/ui/src/components/Table/hooks/useCompletePagination.ts +++ b/packages/ui/src/components/Table/hooks/useCompletePagination.ts @@ -43,7 +43,7 @@ export function useCompletePagination( const getData = useStableCallback(getDataProp); const { sort, filter, search } = query; - const [items, setItems] = useState([]); + const [items, setItems] = useState(undefined); const [isLoading, setIsLoading] = useState(!data); const [error, setError] = useState(undefined); const [loadCount, setLoadCount] = useState(0); @@ -95,6 +95,9 @@ export function useCompletePagination( // Process data client-side (filter, search, sort) const processedData = useMemo(() => { + if (!resolvedItems) { + return undefined; + } let result = [...resolvedItems]; if (filter !== undefined && filterFn) { result = filterFn(result, filter); @@ -108,11 +111,11 @@ export function useCompletePagination( return result; }, [resolvedItems, sort, filter, search, filterFn, searchFn, sortFn]); - const totalCount = processedData.length; + const totalCount = processedData?.length ?? 0; // Paginate the processed data const paginatedData = useMemo( - () => processedData.slice(offset, offset + pageSize), + () => processedData?.slice(offset, offset + pageSize), [processedData, offset, pageSize], ); diff --git a/packages/ui/src/components/Table/index.ts b/packages/ui/src/components/Table/index.ts index 188db635e2..033d51cdaa 100644 --- a/packages/ui/src/components/Table/index.ts +++ b/packages/ui/src/components/Table/index.ts @@ -53,6 +53,7 @@ export type { NoPagination, PagePagination, TablePaginationType, + VirtualizedProp, } from './types'; export type { UseTableOptions, diff --git a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx index 503665ffed..24b8246c4d 100644 --- a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx +++ b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx @@ -798,6 +798,156 @@ export const SelectionReplaceWithRowLinks: Story = { }, }; +export const VirtualizedTable: Story = { + render: () => { + const largeData = Array.from({ length: 500 }, (_, i) => ({ + id: String(i), + name: `Service ${i}`, + owner: { name: `Team ${i % 10}` }, + type: ['service', 'website', 'library'][i % 3], + lifecycle: ['production', 'experimental'][i % 2], + description: `Description for service ${i}`, + })); + + const columns: ColumnConfig<(typeof largeData)[0]>[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( + + ); + }, +}; + +export const VirtualizedWithCustomRowHeight: Story = { + render: () => { + const largeData = Array.from({ length: 500 }, (_, i) => ({ + id: String(i), + name: `Service ${i}`, + owner: { name: `Team ${i % 10}` }, + type: ['service', 'website', 'library'][i % 3], + lifecycle: ['production', 'experimental'][i % 2], + description: `Description for service ${i}`, + })); + + const columns: ColumnConfig<(typeof largeData)[0]>[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( +
+ ); + }, +}; + +export const VirtualizedWithEstimatedRowHeight: Story = { + render: () => { + const largeData = Array.from({ length: 500 }, (_, i) => ({ + id: String(i), + name: `Service ${i}`, + owner: { name: `Team ${i % 10}` }, + type: ['service', 'website', 'library'][i % 3], + lifecycle: ['production', 'experimental'][i % 2], + description: + i % 5 === 0 + ? `This is a much longer description for service ${i} that spans multiple lines to demonstrate variable height row rendering in the virtualized table` + : `Description for service ${i}`, + })); + + const columns: ColumnConfig<(typeof largeData)[0]>[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( +
+ ); + }, +}; + // Type filter interface for ComprehensiveServerSide story interface TypeFilter { type: string | null; diff --git a/packages/ui/src/components/Table/stories/utils.tsx b/packages/ui/src/components/Table/stories/utils.tsx index 45bc6341ad..130469e5d7 100644 --- a/packages/ui/src/components/Table/stories/utils.tsx +++ b/packages/ui/src/components/Table/stories/utils.tsx @@ -17,6 +17,7 @@ import type { Meta } from '@storybook/react-vite'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../../provider'; import { CellText, type ColumnConfig } from '..'; // Selection demo data @@ -47,7 +48,9 @@ export const tableStoriesMeta = { decorators: [ (Story: () => JSX.Element) => ( - + + + ), ], diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index 099a855d33..3cfbdff34e 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -42,6 +42,7 @@ export interface SortState { /** @public */ export type TableRootOwnProps = { stale?: boolean; + loading?: boolean; }; /** @public */ @@ -92,17 +93,35 @@ export interface ColumnProps extends ColumnOwnProps, Omit {} -/** @public */ +/** + * Own props for the {@link Cell} component. + * + * @public + */ export type CellOwnProps = { className?: string; }; -/** @public */ +/** + * Props for the {@link Cell} component. + * + * `Cell` is a generic cell wrapper for custom cell content. When rendering + * cells via {@link ColumnConfig.cell} or a custom {@link RowRenderFn}, the + * returned element **must** be a cell component (`Cell`, `CellText`, or + * `CellProfile`) at the top level. Returning bare text or other elements + * without a cell wrapper will break the table layout. + * + * @public + */ export interface CellProps extends CellOwnProps, Omit {} -/** @public */ +/** + * Own props for the {@link CellText} component. + * + * @public + */ export type CellTextOwnProps = { title: string; description?: string; @@ -112,12 +131,25 @@ export type CellTextOwnProps = { className?: string; }; -/** @public */ +/** + * Props for the {@link CellText} component. + * + * `CellText` renders a table cell with a title and optional description. It + * is one of the cell components (`Cell`, `CellText`, `CellProfile`) that + * **must** be used as the top-level element returned from + * {@link ColumnConfig.cell} or a custom {@link RowRenderFn}. + * + * @public + */ export interface CellTextProps extends CellTextOwnProps, Omit {} -/** @public */ +/** + * Own props for the {@link CellProfile} component. + * + * @public + */ export type CellProfileOwnProps = { src?: string; name?: string; @@ -127,7 +159,16 @@ export type CellProfileOwnProps = { className?: string; }; -/** @public */ +/** + * Props for the {@link CellProfile} component. + * + * `CellProfile` renders a table cell with an avatar, name, and optional + * description. It is one of the cell components (`Cell`, `CellText`, + * `CellProfile`) that **must** be used as the top-level element returned + * from {@link ColumnConfig.cell} or a custom {@link RowRenderFn}. + * + * @public + */ export interface CellProfileProps extends CellProfileOwnProps, Omit {} @@ -150,10 +191,27 @@ export interface PagePagination extends TablePaginationProps { /** @public */ export type TablePaginationType = NoPagination | PagePagination; -/** @public */ +/** + * Configuration for a single table column. + * + * @public + */ export interface ColumnConfig { id: string; label: string; + /** + * Renders the cell content for this column. + * + * **Important:** The returned element **must** be a cell component at the + * top level — either `Cell`, `CellText`, or `CellProfile`. Returning bare + * text, fragments, or other elements without a cell wrapper will break the + * table layout. + * + * @example + * ```tsx + * cell: item => + * ``` + */ cell: (item: T) => ReactElement; header?: () => ReactElement; isSortable?: boolean; @@ -172,7 +230,16 @@ export interface RowConfig { getIsDisabled?: (item: T) => boolean; } -/** @public */ +/** + * Custom render function for table rows. + * + * When using a custom row render function, each cell rendered inside the row + * **must** use a cell component (`Cell`, `CellText`, or `CellProfile`) as + * the top-level element. Returning bare text or other elements without a + * cell wrapper will break the table layout. + * + * @public + */ export type RowRenderFn = (params: { item: T; index: number; @@ -186,6 +253,12 @@ export interface TableSelection { onSelectionChange?: ReactAriaTableProps['onSelectionChange']; } +/** @public */ +export type VirtualizedProp = + | boolean + | { rowHeight: number } + | { estimatedRowHeight: number }; + /** @public */ export interface TableProps { columnConfig: readonly ColumnConfig[]; @@ -200,4 +273,5 @@ export interface TableProps { emptyState?: ReactNode; className?: string; style?: React.CSSProperties; + virtualized?: VirtualizedProp; } diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 55cdf7c446..99bf75a072 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -18,6 +18,7 @@ import preview from '../../../../../.storybook/preview'; import type { StoryFn } from '@storybook/react-vite'; import { Tabs, TabList, Tab, TabPanel } from './Tabs'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { Box } from '../Box'; import { Text } from '../Text'; @@ -28,7 +29,9 @@ const meta = preview.meta({ const withRouter = (Story: StoryFn) => ( - + + + ); @@ -79,28 +82,30 @@ export const WithMockedURLTab2 = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /tab2 - - - Notice how the "Tab 2" tab is selected (highlighted) because it - matches the current path. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /tab2 + + + Notice how the "Tab 2" tab is selected (highlighted) because it + matches the current path. + + + ), }); @@ -111,28 +116,30 @@ export const WithMockedURLTab3 = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /tab3 - - - Notice how the "Tab 3 With long title" tab is selected (highlighted) - because it matches the current path. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /tab3 + + + Notice how the "Tab 3 With long title" tab is selected (highlighted) + because it matches the current path. + + + ), }); @@ -143,32 +150,34 @@ export const WithMockedURLNoMatch = meta.story({ }, render: () => ( - - - - Tab 1 - - - Tab 2 - - - Tab 3 With long title - - - - - - Current URL is mocked to be: /some-other-page - - - No tab is selected because the current path doesn't match any tab's - href. - - - Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") - fall back to React Aria's internal state. - - + + + + + Tab 1 + + + Tab 2 + + + Tab 3 With long title + + + + + + Current URL is mocked to be: /some-other-page + + + No tab is selected because the current path doesn't match any tab's + href. + + + Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title") + fall back to React Aria's internal state. + + + ), }); @@ -181,32 +190,34 @@ export const ExactMatchingDefault = meta.story({ }, render: () => ( - - - - Mentorship - - - Events - - - Catalog - - - - - - Current URL: /mentorship/events - - - Using default exact matching, only the "Events" tab is active because - it exactly matches the URL. - - - The "Mentorship" tab is NOT active even though the URL contains - "/mentorship". - - + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + Using default exact matching, only the "Events" tab is active + because it exactly matches the URL. + + + The "Mentorship" tab is NOT active even though the URL contains + "/mentorship". + + + ), }); @@ -217,36 +228,38 @@ export const PrefixMatchingForNestedRoutes = meta.story({ }, render: () => ( - - - - Mentorship - - - Events - - - Catalog - - - - - - Current URL: /mentorship/events - - - The "Mentorship" tab uses prefix matching and IS active because - "/mentorship/events" starts with "/mentorship". - - - The "Events" tab uses exact matching and is also active because it - exactly matches. - - - The "Catalog" tab uses prefix matching but is NOT active because the - URL doesn't start with "/catalog". - - + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + The "Mentorship" tab uses prefix matching and IS active because + "/mentorship/events" starts with "/mentorship". + + + The "Events" tab uses exact matching and is also active because it + exactly matches. + + + The "Catalog" tab uses prefix matching but is NOT active because the + URL doesn't start with "/catalog". + + + ), }); @@ -257,31 +270,33 @@ export const PrefixMatchingDeepNesting = meta.story({ }, render: () => ( - - - - Home - - - Catalog - - - Mentorship - - - - - - Current URL: /catalog/users/john/details - - - The "Catalog" tab is active because it uses prefix matching and the - URL starts with "/catalog". - - - This works for any level of nesting under "/catalog". - - + + + + + Home + + + Catalog + + + Mentorship + + + + + + Current URL: /catalog/users/john/details + + + The "Catalog" tab is active because it uses prefix matching and the + URL starts with "/catalog". + + + This works for any level of nesting under "/catalog". + + + ), }); @@ -292,47 +307,53 @@ export const MixedMatchingStrategies = meta.story({ }, render: () => ( - - - - Overview - - - Analytics - - - Settings - - - Help - - - - - - Current URL: /dashboard/analytics/reports - - - • "Overview" tab: exact matching, NOT active (doesn't exactly match - "/dashboard") - - - • "Analytics" tab: prefix matching, IS active (URL starts with - "/dashboard/analytics") - - - • "Settings" tab: prefix matching, NOT active (URL doesn't start with - "/dashboard/settings") - - - • "Help" tab: exact matching, NOT active (doesn't exactly match - "/help") - - + + + + + Overview + + + Analytics + + + Settings + + + Help + + + + + + Current URL: /dashboard/analytics/reports + + + • "Overview" tab: exact matching, NOT active (doesn't exactly match + "/dashboard") + + + • "Analytics" tab: prefix matching, IS active (URL starts with + "/dashboard/analytics") + + + • "Settings" tab: prefix matching, NOT active (URL doesn't start + with "/dashboard/settings") + + + • "Help" tab: exact matching, NOT active (doesn't exactly match + "/help") + + + ), }); @@ -343,38 +364,40 @@ export const PrefixMatchingEdgeCases = meta.story({ }, render: () => ( - - - - Foo - - - Foobar - - - Foo (exact) - - - - - - Current URL: /foobar - - - • "Foo" tab (prefix): NOT active - prevents "/foo" from matching - "/foobar" - - - • "Foobar" tab (exact): IS active - exactly matches "/foobar" - - - • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" - - - This shows that prefix matching properly requires a "/" separator to - prevent false matches. - - + + + + + Foo + + + Foobar + + + Foo (exact) + + + + + + Current URL: /foobar + + + • "Foo" tab (prefix): NOT active - prevents "/foo" from matching + "/foobar" + + + • "Foobar" tab (exact): IS active - exactly matches "/foobar" + + + • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" + + + This shows that prefix matching properly requires a "/" separator to + prevent false matches. + + + ), }); @@ -385,37 +408,39 @@ export const PrefixMatchingWithSlash = meta.story({ }, render: () => ( - - - - Foo - - - Foobar - - - Bar - - - - - - Current URL: /foo/bar - - - • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" - - - • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" - - - • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with - "/bar" - - - This demonstrates proper prefix matching with the "/" separator. - - + + + + + Foo + + + Foobar + + + Bar + + + + + + Current URL: /foo/bar + + + • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" + + + • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" + + + • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with + "/bar" + + + This demonstrates proper prefix matching with the "/" separator. + + + ), }); @@ -426,32 +451,34 @@ export const RootPathMatching = meta.story({ }, render: () => ( - - - - Home - - - Home (prefix) - - - Catalog - - - - - - Current URL: / - - - • "Home" tab (exact): IS active - exactly matches "/" - - • "Home (prefix)" tab: IS active - "/" matches "/" - - • "Catalog" tab (prefix): NOT active - "/" doesn't start with - "/catalog" - - + + + + + Home + + + Home (prefix) + + + Catalog + + + + + + Current URL: / + + + • "Home" tab (exact): IS active - exactly matches "/" + + • "Home (prefix)" tab: IS active - "/" matches "/" + + • "Catalog" tab (prefix): NOT active - "/" doesn't start with + "/catalog" + + + ), }); @@ -462,41 +489,43 @@ export const HrefWithQueryParams = meta.story({ }, render: () => ( - - - - Dashboard - - - Alerts - - - - - - Current URL: /cost-insights/dashboard?group=bar - - - Tab hrefs include query params (e.g., ?group=foo) but the current URL - has different query params (?group=bar). - - - • "Dashboard" tab: IS active — matching ignores query params and - compares only the pathname. - - - • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't - match /cost-insights/dashboard. - - + + + + + Dashboard + + + Alerts + + + + + + Current URL: /cost-insights/dashboard?group=bar + + + Tab hrefs include query params (e.g., ?group=foo) but the current + URL has different query params (?group=bar). + + + • "Dashboard" tab: IS active — matching ignores query params and + compares only the pathname. + + + • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't + match /cost-insights/dashboard. + + + ), }); @@ -507,55 +536,57 @@ export const AutoSelectionOfTabs = meta.story({ }, render: () => ( -
- - Current URL: /random-page - + +
+ + Current URL: /random-page + - {/* Without hrefs */} - - {' '} - Case 1: Without hrefs - - - - Settings - Preferences - Advanced - - - Settings content - React Aria manages this selection - - - Preferences content - works normally - - - Advanced content - local state only - - + {/* Without hrefs */} + + {' '} + Case 1: Without hrefs + + + + Settings + Preferences + Advanced + + + Settings content - React Aria manages this selection + + + Preferences content - works normally + + + Advanced content - local state only + + - {/* With hrefs */} - - Case 2: With hrefs - - - By default no selection is shown because the URL doesn't match any - tab's href. - - - - - Catalog - - - Create - - - Docs - - - -
+ {/* With hrefs */} + + Case 2: With hrefs + + + By default no selection is shown because the URL doesn't match any + tab's href. + + + + + Catalog + + + Create + + + Docs + + + +
+
), }); diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index fcab2c71b9..c489a2ab67 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -50,15 +50,9 @@ import { TabDefinition, TabPanelDefinition, } from './definition'; -import { - isInternalLink, - createRoutingRegistration, -} from '../InternalLinkProvider'; +import { isInternalLink } from '../../utils/linkUtils'; import { getNodeText } from '../../analytics/getNodeText'; -const { RoutingProvider, useRoutingRegistrationEffect } = - createRoutingRegistration(); - const TabsContext = createContext(undefined); const useTabsContext = () => { @@ -218,21 +212,19 @@ export const Tabs = (props: TabsProps) => { ); return ( - - - - - {children as ReactNode} - - - - + + + + {children as ReactNode} + + + ); }; @@ -299,9 +291,6 @@ function RoutedTabEffects({ const selectionCtx = useContext(TabSelectionContext); const location = useLocation(); - // Register with RoutingProvider for conditional RouterProvider wrapping - useRoutingRegistrationEffect(href); - // Register as a routed tab (for controlled vs uncontrolled mode) useEffect(() => { if (selectionCtx) { diff --git a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx index 7d4ee4173e..00c778757f 100644 --- a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx +++ b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx @@ -21,6 +21,7 @@ import type { Selection } from 'react-aria-components'; import { Flex } from '../../'; import { useListData } from 'react-stately'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../../provider'; import { RiAccountCircleLine, RiBugLine, @@ -50,7 +51,9 @@ const meta = preview.meta({ decorators: [ Story => ( - + + + ), ], diff --git a/packages/ui/src/components/TagGroup/TagGroup.tsx b/packages/ui/src/components/TagGroup/TagGroup.tsx index 8258ab8554..9fd17c6d5c 100644 --- a/packages/ui/src/components/TagGroup/TagGroup.tsx +++ b/packages/ui/src/components/TagGroup/TagGroup.tsx @@ -25,12 +25,8 @@ import { forwardRef, type ReactNode } from 'react'; import { RiCloseCircleLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; import { TagGroupDefinition, TagDefinition } from './definition'; -import { createRoutingRegistration } from '../InternalLinkProvider'; import { getNodeText } from '../../analytics/getNodeText'; -const { RoutingProvider, useRoutingRegistrationEffect } = - createRoutingRegistration(); - /** * A component that renders a list of tags. * @@ -41,17 +37,15 @@ export const TagGroup = (props: TagGroupProps) => { const { classes, items, children, renderEmptyState } = ownProps; return ( - - - - {children} - - - + + + {children} + + ); }; @@ -68,8 +62,6 @@ export const Tag = forwardRef((props, ref) => { const { classes, children, icon, href } = ownProps; const textValue = typeof children === 'string' ? children : undefined; - useRoutingRegistrationEffect(href); - const handlePress = () => { if (href) { const text = diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index a07fff7462..79c5a8b884 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -52,6 +52,10 @@ export { MenuDefinition } from './components/Menu/definition'; export { PasswordFieldDefinition } from './components/PasswordField/definition'; export { PopoverDefinition } from './components/Popover/definition'; export { RadioGroupDefinition } from './components/RadioGroup/definition'; +export { + SearchAutocompleteDefinition, + SearchAutocompleteItemDefinition, +} from './components/SearchAutocomplete/definition'; export { SearchFieldDefinition } from './components/SearchField/definition'; export { SelectDefinition } from './components/Select/definition'; export { SkeletonDefinition } from './components/Skeleton/definition'; diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx index a8ecaf0bef..23e5923327 100644 --- a/packages/ui/src/guidelines/CardsWithTable.stories.tsx +++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx @@ -18,6 +18,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { MemoryRouter } from 'react-router-dom'; +import { BUIProvider } from '../provider'; import { Card, CardHeader, @@ -265,7 +266,9 @@ const meta = { decorators: [ (Story: () => JSX.Element) => ( - + + + ), ], diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 290ae2a80f..8122fd5fde 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -51,6 +51,7 @@ export * from './components/PasswordField'; export * from './components/Tooltip'; export * from './components/Menu'; export * from './components/Popover'; +export * from './components/SearchAutocomplete'; export * from './components/SearchField'; export * from './components/Link'; export * from './components/Select'; @@ -68,11 +69,14 @@ export { useBreakpoint } from './hooks/useBreakpoint'; export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg'; export type { BgContextValue, BgProviderProps } from './hooks/useBg'; +// Provider +export { BUIProvider } from './provider'; +export type { BUIProviderProps } from './provider'; + // Analytics -export { useAnalytics, BUIProvider, getNodeText } from './analytics'; +export { useAnalytics, getNodeText } from './analytics'; export type { AnalyticsTracker, AnalyticsEventAttributes, UseAnalyticsFn, - BUIProviderProps, } from './analytics'; diff --git a/packages/ui/src/analytics/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx similarity index 68% rename from packages/ui/src/analytics/BUIProvider.tsx rename to packages/ui/src/provider/BUIProvider.tsx index 3fcbfc7698..6576a9eabb 100644 --- a/packages/ui/src/analytics/BUIProvider.tsx +++ b/packages/ui/src/provider/BUIProvider.tsx @@ -15,9 +15,11 @@ */ import { useMemo, type ReactNode } from 'react'; +import { RouterProvider } from 'react-aria-components'; +import { useInRouterContext, useNavigate, useHref } from 'react-router-dom'; import { createVersionedValueMap } from '@backstage/version-bridge'; -import { BUIContext } from './useAnalytics'; -import type { UseAnalyticsFn } from './types'; +import { BUIContext } from '../analytics/useAnalytics'; +import type { UseAnalyticsFn } from '../analytics/types'; /** @public */ export type BUIProviderProps = { @@ -53,5 +55,23 @@ export function BUIProvider(props: BUIProviderProps) { }), [useAnalytics], ); - return {children}; + + const content = ( + {children} + ); + + if (useInRouterContext()) { + return {content}; + } + + return content; +} + +function RoutedContent({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + return ( + + {children} + + ); } diff --git a/packages/ui/src/provider/index.ts b/packages/ui/src/provider/index.ts new file mode 100644 index 0000000000..7a99794d47 --- /dev/null +++ b/packages/ui/src/provider/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { BUIProvider } from './BUIProvider'; +export type { BUIProviderProps } from './BUIProvider'; diff --git a/packages/ui/src/utils/isExternalLink.ts b/packages/ui/src/utils/linkUtils.ts similarity index 85% rename from packages/ui/src/utils/isExternalLink.ts rename to packages/ui/src/utils/linkUtils.ts index a874579ab5..8b2ded55c4 100644 --- a/packages/ui/src/utils/isExternalLink.ts +++ b/packages/ui/src/utils/linkUtils.ts @@ -40,3 +40,12 @@ export function isExternalLink(href?: string): boolean { return false; } + +/** + * Checks if an href is an internal link (not external and not empty). + * + * @internal + */ +export function isInternalLink(href: string | undefined): href is string { + return !!href && !isExternalLink(href); +} diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index 0326e3d4e9..b54b422312 100644 --- a/packages/yarn-plugin/package.json +++ b/packages/yarn-plugin/package.json @@ -46,7 +46,7 @@ "@yarnpkg/builder": "^4.2.1", "fs-extra": "^11.2.0", "nodemon": "^3.0.1", - "snyk-nodejs-lockfile-parser": "^1.58.14", + "snyk-nodejs-lockfile-parser": "^2.0.0", "yaml": "^2.0.0" } } diff --git a/plugins/api-docs/README-alpha.md b/plugins/api-docs/README-alpha.md index 0f19f51b23..a4c3979787 100644 --- a/plugins/api-docs/README-alpha.md +++ b/plugins/api-docs/README-alpha.md @@ -1,8 +1,6 @@ -# Api Docs +# Api Docs - Extension Reference -> [!WARNING] -> This documentation is made for those using the experimental new Frontend system. -> If you are not using the new frontend system, please go [here](./README.md). +This page contains detailed documentation for all extensions provided by the `@backstage/plugin-api-docs` plugin. For general information about the plugin, see the [README](./README.md). This is an extension for the catalog plugin that provides components to discover and display API entities. APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details. diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 139a97d4c8..f71b796ec4 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -1,8 +1,5 @@ # API Documentation -> Disclaimer: -> If you are looking for documentation on the experimental new frontend system support, please go [here](./README-alpha.md). - This is an extension for the catalog plugin that provides components to discover and display API entities. APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details. They are defined in machine readable formats and provide a human readable documentation. @@ -28,77 +25,32 @@ To link that a component provides or consumes an API, see the [`providesApis`](h > The plugin is already added when using `npx @backstage/create-app` so you can skip these steps. -1. Install the API docs plugin - ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-api-docs ``` -2. Add the `ApiExplorerPage` extension to the app: +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). -```tsx -// In packages/app/src/App.tsx +You can enable entity cards and tabs on the catalog entity page through configuration: -import { ApiExplorerPage } from '@backstage/plugin-api-docs'; - -} />; +```yaml +# app-config.yaml +app: + extensions: + - entity-card:api-docs/providing-components: + config: + filter: + kind: api + - entity-card:api-docs/consuming-components: + config: + filter: + kind: api + - entity-content:api-docs/definition + - entity-content:api-docs/apis ``` -3. Add one of the provided widgets to the EntityPage: - -```tsx -// packages/app/src/components/catalog/EntityPage.tsx - -import { - EntityAboutCard, - EntityApiDefinitionCard, - EntityConsumingComponentsCard, - EntityProvidingComponentsCard, -} from '@backstage/plugin-api-docs'; - -const apiPage = ( - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -// ... - -export const entityPage = ( - - // ... - - // ... - -); -``` - -There are other components to discover in [`./src/components`](./src/components) that are also added by the default app. +For the full list of available extensions and their configuration options, see the [README-alpha.md](./README-alpha.md). ## Customizations @@ -388,6 +340,75 @@ import { ApiExplorerPage } from '@backstage/plugin-api-docs'; />; ``` +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + +1. Add the `ApiExplorerPage` extension to the app: + +```tsx +// In packages/app/src/App.tsx + +import { ApiExplorerPage } from '@backstage/plugin-api-docs'; + +} />; +``` + +2. Add one of the provided widgets to the EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { + EntityAboutCard, + EntityApiDefinitionCard, + EntityConsumingComponentsCard, + EntityProvidingComponentsCard, +} from '@backstage/plugin-api-docs'; + +const apiPage = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +// ... + +export const entityPage = ( + + // ... + + // ... + +); +``` + +There are other components to discover in [`./src/components`](./src/components) that are also added by the default app. + ## Links - [The Backstage homepage](https://backstage.io) diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 4afe97dee9..2724619a68 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -43,7 +43,7 @@ const apiDocsNavItem = NavItemBlueprint.make({ params: { title: 'APIs', routeRef: rootRoute, - icon: () => , + icon: () => , }, }); @@ -211,7 +211,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ export default createFrontendPlugin({ pluginId: 'api-docs', title: 'APIs', - icon: , + icon: , info: { packageJson: () => import('../package.json') }, routes: { root: rootRoute, diff --git a/plugins/app-visualizer/dev/index.ts b/plugins/app-visualizer/dev/index.ts index fa49e3360b..5a5222ffbf 100644 --- a/plugins/app-visualizer/dev/index.ts +++ b/plugins/app-visualizer/dev/index.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import ReactDOM from 'react-dom/client'; -import { createApp } from '@backstage/frontend-defaults'; +import { createDevApp } from '@backstage/frontend-dev-utils'; import { default as plugin } from '../src'; -const app = createApp({ - features: [plugin], -}); - -ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot()); +createDevApp({ features: [plugin] }); diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json index 4c2a456f5b..bf71701775 100644 --- a/plugins/app-visualizer/package.json +++ b/plugins/app-visualizer/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/frontend-defaults": "workspace:^", + "@backstage/frontend-dev-utils": "workspace:^", "@types/react": "^18.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index b1d851dab9..0262c4c1ef 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -19,6 +19,7 @@ import { JSX as JSX_2 } from 'react'; import { NavContentComponent } from '@backstage/plugin-app-react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SignInPageProps } from '@backstage/plugin-app-react'; @@ -620,11 +621,7 @@ const appPlugin: OverridableFrontendPlugin< inputs: { wrappers: ExtensionInput< ConfigurableExtensionDataRef< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, + () => Promise, 'core.plugin-wrapper.loader', {} >, diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx index c723222f2f..bbf9ce6294 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx @@ -16,6 +16,34 @@ import { render, screen } from '@testing-library/react'; import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi'; +import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api'; +import { ReactNode, useState } from 'react'; +import userEvent from '@testing-library/user-event'; + +type TestInc = { count: number; increment: () => void }; + +function useTestInc(): TestInc { + const [value, setValue] = useState(0); + return { + count: value, + increment: () => setValue(val => val + 1), + }; +} + +function makeTestIncWrapper( + label: string = '', + renderSpy?: () => void, +): (props: { children: ReactNode; value: TestInc }) => JSX.Element { + return ({ children, value }: { children: ReactNode; value: TestInc }) => { + renderSpy?.(); + return ( +
+ Wrapper{label}#{value.count} {children} + +
+ ); + }; +} describe('DefaultPluginWrapperApi', () => { it('should wrap multiple components with a single wrapper', async () => { @@ -36,8 +64,10 @@ describe('DefaultPluginWrapperApi', () => { expect(Wrapper2).toBeDefined(); expect(Wrapper3).toBeDefined(); + const RootWrapper = api.getRootWrapper(); + render( - <> +
1
@@ -47,7 +77,7 @@ describe('DefaultPluginWrapperApi', () => {
3
- , +
, ); await expect(screen.findByText('Wrapper(1)')).resolves.toBeInTheDocument(); @@ -77,15 +107,17 @@ describe('DefaultPluginWrapperApi', () => { expect(Wrapper1).toBeDefined(); expect(Wrapper2).toBeDefined(); + const RootWrapper = api.getRootWrapper(); + render( - <> +
1
2
- , +
, ); await expect( @@ -95,4 +127,153 @@ describe('DefaultPluginWrapperApi', () => { screen.findByText('WrapperB(WrapperA(2))'), ).resolves.toBeInTheDocument(); }); + + it('should share a single value across multiple wrappers', async () => { + const api = DefaultPluginWrapperApi.fromWrappers([ + { + loader: async (): Promise> => ({ + component: ({ children, value }) => ( + <> + Wrapper({children}:{value}) + + ), + useWrapperValue: () => 'foo', + }), + pluginId: 'plugin-1', + }, + ]); + + const Wrapper1 = api.getPluginWrapper('plugin-1')!; + const Wrapper2 = api.getPluginWrapper('plugin-1')!; + + expect(Wrapper1).toBeDefined(); + expect(Wrapper2).toBeDefined(); + + const RootWrapper = api.getRootWrapper(); + + render( + +
+ 1 +
+
+ 2 +
+
, + ); + + await expect( + screen.findByText('Wrapper(1:foo)'), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText('Wrapper(2:foo)'), + ).resolves.toBeInTheDocument(); + }); + + it('should share a single stateful value across multiple wrappers', async () => { + const api = DefaultPluginWrapperApi.fromWrappers([ + { + loader: async (): Promise> => ({ + component: makeTestIncWrapper(), + useWrapperValue: useTestInc, + }), + pluginId: 'plugin-1', + }, + ]); + + const Wrapper1 = api.getPluginWrapper('plugin-1')!; + const Wrapper2 = api.getPluginWrapper('plugin-1')!; + + expect(Wrapper1).toBeDefined(); + expect(Wrapper2).toBeDefined(); + + const RootWrapper = api.getRootWrapper(); + + render( + + X + Y + , + ); + + await expect(screen.findByText('Wrapper#0 X')).resolves.toBeInTheDocument(); + await expect(screen.findByText('Wrapper#0 Y')).resolves.toBeInTheDocument(); + + await userEvent.click(screen.getAllByText('Increment')[0]); + + await expect(screen.findByText('Wrapper#1 X')).resolves.toBeInTheDocument(); + await expect(screen.findByText('Wrapper#1 Y')).resolves.toBeInTheDocument(); + + await userEvent.click(screen.getAllByText('Increment')[1]); + + await expect(screen.findByText('Wrapper#2 X')).resolves.toBeInTheDocument(); + await expect(screen.findByText('Wrapper#2 Y')).resolves.toBeInTheDocument(); + }); + + it('should not rerender adjacent hooks on update', async () => { + let renderCountA = 0; + let renderCountB = 0; + + const api = DefaultPluginWrapperApi.fromWrappers([ + { + loader: async (): Promise> => ({ + component: makeTestIncWrapper('A', () => { + renderCountA += 1; + }), + useWrapperValue: useTestInc, + }), + pluginId: 'plugin-a', + }, + { + loader: async (): Promise> => ({ + component: makeTestIncWrapper('B', () => { + renderCountB += 1; + }), + useWrapperValue: useTestInc, + }), + pluginId: 'plugin-b', + }, + ]); + + const WrapperA = api.getPluginWrapper('plugin-a')!; + const WrapperB = api.getPluginWrapper('plugin-b')!; + + expect(WrapperA).toBeDefined(); + expect(WrapperB).toBeDefined(); + + const RootWrapper = api.getRootWrapper(); + + render( + + X + Y + , + ); + + await expect( + screen.findByText('WrapperA#0 X'), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByText('WrapperB#0 Y'), + ).resolves.toBeInTheDocument(); + + expect(renderCountA).toBe(1); + expect(renderCountB).toBe(1); + + await userEvent.click(screen.getByText('IncrementA')); + + expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument(); + expect(screen.getByText('WrapperB#0 Y')).toBeInTheDocument(); + + expect(renderCountA).toBe(2); + expect(renderCountB).toBe(1); + + await userEvent.click(screen.getByText('IncrementB')); + + expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument(); + expect(screen.getByText('WrapperB#1 Y')).toBeInTheDocument(); + + expect(renderCountA).toBe(2); + expect(renderCountB).toBe(2); + }); }); diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx index 7e602ee23f..1c6037a6f3 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx @@ -14,11 +14,36 @@ * limitations under the License. */ -import { PluginWrapperApi } from '@backstage/frontend-plugin-api/alpha'; -import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react'; +import { + PluginWrapperApi, + PluginWrapperDefinition, +} from '@backstage/frontend-plugin-api'; +import { + ComponentType, + ReactNode, + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from 'react'; + +interface HookStore { + getSnapshot: () => { value: unknown } | undefined; + subscribe: (listener: () => void) => () => void; +} + +interface HookRegistryContextValue { + registerHook: (key: any, hook: () => unknown) => HookStore; +} + +const HookRegistryContext = createContext( + undefined, +); type WrapperInput = { - loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>; + loader: () => Promise>; pluginId: string; }; @@ -29,12 +54,17 @@ type WrapperInput = { */ export class DefaultPluginWrapperApi implements PluginWrapperApi { constructor( + private readonly rootWrapper: ComponentType<{ children: ReactNode }>, private readonly pluginWrappers: Map< string, ComponentType<{ children: ReactNode }> >, ) {} + getRootWrapper(): ComponentType<{ children: ReactNode }> { + return this.rootWrapper; + } + getPluginWrapper( pluginId: string, ): ComponentType<{ children: ReactNode }> | undefined { @@ -44,9 +74,7 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { static fromWrappers(wrappers: Array): DefaultPluginWrapperApi { const loadersByPlugin = new Map< string, - Array< - () => Promise<{ component: ComponentType<{ children: ReactNode }> }> - > + Array<() => Promise>> >(); for (const wrapper of wrappers) { @@ -68,6 +96,45 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { continue; } + const WrapperWithState = ({ + loader, + component: WrapperComponent, + useWrapperValue, + children, + }: { + loader: () => Promise; + component: ComponentType<{ + children: ReactNode; + value: unknown; + }>; + useWrapperValue: () => unknown; + children: ReactNode; + }) => { + const hookContext = useContext(HookRegistryContext); + if (!hookContext) { + throw new Error( + 'Attempted to render a wrapped plugin component without a root wrapper context', + ); + } + const store = useMemo(() => { + return hookContext.registerHook(loader, useWrapperValue); + }, [hookContext, loader, useWrapperValue]); + const container = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + ); + + if (!container) { + return null; + } + + return ( + + {children} + + ); + }; + const ComposedWrapper = (props: { children: ReactNode }) => { const [loadedWrappers, setLoadedWrappers] = useState< Array> | undefined @@ -77,7 +144,27 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { useEffect(() => { Promise.all(loaders.map(loader => loader())) .then(results => { - setLoadedWrappers(results.map(r => r.component)); + const normalizedResults = results.map( + ({ component, useWrapperValue }, index) => { + const loader = loaders[index]; + + if (!useWrapperValue) { + return component as ComponentType<{ children: ReactNode }>; + } + + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); + }, + ); + + setLoadedWrappers(normalizedResults); }) .catch(setError); }, []); @@ -86,24 +173,107 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { throw error; } - return useMemo(() => { - if (!loadedWrappers) { - return null; - } + if (!loadedWrappers) { + return null; + } - let current = props.children; + let content = props.children; - for (const Wrapper of loadedWrappers) { - current = {current}; - } + for (const Wrapper of loadedWrappers) { + content = {content}; + } - return current; - }, [loadedWrappers, props.children]); + return <>{content}; }; composedWrappers.set(pluginId, ComposedWrapper); } - return new DefaultPluginWrapperApi(composedWrappers); + return new DefaultPluginWrapperApi( + DefaultPluginWrapperApi.createRootWrapper(), + composedWrappers, + ); + } + + /** + * Creates the root wrapper component that is responsible for rendering and + * forwarding the values of the common `useWrapperValue` hooks. + */ + static createRootWrapper() { + const renderers = new Map(); + const renderUpdateListeners = new Set<() => void>(); + + let renderElements = new Array(); + + const createHookRenderer = (hook: () => unknown): HookStore => { + const listeners = new Set<() => void>(); + let container: { value: unknown } | undefined = undefined; + + const HookRenderer = () => { + container = { value: hook() }; + useEffect(() => { + for (const listener of listeners) { + listener(); + } + }); + return null; + }; + + renderElements = [ + ...renderElements, + , + ]; + + return { + getSnapshot: () => container, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + }; + + const registerHook = (key: any, hook: () => unknown) => { + let renderer = renderers.get(key); + if (!renderer) { + renderer = createHookRenderer(hook); + renderers.set(key, renderer); + + queueMicrotask(() => { + for (const listener of renderUpdateListeners) { + listener(); + } + }); + } + return renderer; + }; + + const subscribeToRenderUpdates = (listener: () => void) => { + renderUpdateListeners.add(listener); + return () => renderUpdateListeners.delete(listener); + }; + const getRenderElements = () => renderElements; + + const RootWrapper = (props: { children: ReactNode }) => { + const elements = useSyncExternalStore( + subscribeToRenderUpdates, + getRenderElements, + ); + + return ( + <> + <>{elements} + + {props.children} + + + ); + }; + + return RootWrapper; } } diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx index ffa6ced7b0..129a94cd9e 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -172,6 +172,7 @@ function NavContentRenderer(props: { // We want the priority: page (config/params) -> nav item -> plugin -> pluginId const resolvedTitle = node.instance.getData(coreExtensionData.title); const pluginTitle = node.spec.plugin.title; + const pluginIcon = node.spec.plugin.icon; const pluginId = node.spec.plugin.pluginId; const hasExplicitPageTitle = resolvedTitle !== undefined && @@ -194,6 +195,8 @@ function NavContentRenderer(props: { icon = ; } else if (resolvedIcon) { icon = resolvedIcon; + } else if (pluginIcon) { + icon = pluginIcon; } if (!title || !icon) { diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index e43e4b46e4..9ae564e06d 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -29,6 +29,7 @@ import { createExtension, createExtensionInput, routeResolutionApiRef, + pluginWrapperApiRef, useAnalytics, } from '@backstage/frontend-plugin-api'; import { @@ -115,23 +116,27 @@ export const AppRoot = createExtension({ } } + const pluginWrapperApi = apis.get(pluginWrapperApiRef); + const RootWrapper = pluginWrapperApi?.getRootWrapper(); + if (RootWrapper) { + content = {content}; + } + return [ coreExtensionData.reactElement( - - - el.get(coreExtensionData.reactElement), - )} - > - {content} - - , + + el.get(coreExtensionData.reactElement), + )} + > + {content} + , ), ]; }, @@ -273,23 +278,27 @@ export function AppRouter(props: AppRouterProps) { return ( - {...extraElements} - - {children} + + {...extraElements} + + {children} + ); } return ( - {...extraElements} - - - {children} - + + {...extraElements} + + + {children} + + ); } diff --git a/plugins/app/src/extensions/IconsApi.ts b/plugins/app/src/extensions/IconsApi.tsx similarity index 100% rename from plugins/app/src/extensions/IconsApi.ts rename to plugins/app/src/extensions/IconsApi.tsx diff --git a/plugins/app/src/extensions/PluginWrapperApi.ts b/plugins/app/src/extensions/PluginWrapperApi.ts index e402f094f1..836f563fee 100644 --- a/plugins/app/src/extensions/PluginWrapperApi.ts +++ b/plugins/app/src/extensions/PluginWrapperApi.ts @@ -17,8 +17,6 @@ import { PluginWrapperBlueprint, pluginWrapperApiRef, -} from '@backstage/frontend-plugin-api/alpha'; -import { createExtensionInput, ApiBlueprint, } from '@backstage/frontend-plugin-api'; diff --git a/plugins/auth/package.json b/plugins/auth/package.json index 5b51833436..52a51d81ce 100644 --- a/plugins/auth/package.json +++ b/plugins/auth/package.json @@ -47,13 +47,10 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/core-components": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/theme": "workspace:^", - "@material-ui/core": "^4.12.2", - "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.61", + "@backstage/ui": "workspace:^", + "@remixicon/react": "^4.6.0", "react-use": "^17.2.4" }, "devDependencies": { diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.module.css b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css new file mode 100644 index 0000000000..049c512c54 --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css @@ -0,0 +1,44 @@ +.card { + max-width: 600px; + margin: var(--bui-space-8) auto 0; +} + +.appHeader { + display: flex; + align-items: center; + gap: var(--bui-space-4); +} + +.appIcon { + color: var(--bui-fg-secondary); +} + +.divider { + border: none; + border-top: 1px solid var(--bui-border-1); + margin: 0; +} + +.callbackUrl { + font-family: var(--bui-font-monospace); + background: var(--bui-bg-neutral-2); + padding: var(--bui-space-2); + border-radius: var(--bui-radius-2); + word-break: break-all; + font-size: var(--bui-font-size-3); + margin-top: var(--bui-space-2); +} + +.completedIcon { + margin-bottom: var(--bui-space-4); +} + +.completedIconSuccess { + composes: completedIcon; + color: var(--bui-fg-success); +} + +.completedIconDanger { + composes: completedIcon; + color: var(--bui-fg-danger); +} diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx index 859d82fe04..59e7b512a5 100644 --- a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx @@ -16,89 +16,37 @@ import { useParams } from 'react-router-dom'; import { + Alert, Box, Button, Card, - CardActions, - CardContent, - Divider, - makeStyles, - Typography, -} from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import CheckCircleIcon from '@material-ui/icons/CheckCircle'; -import CancelIcon from '@material-ui/icons/Cancel'; -import AppsIcon from '@material-ui/icons/Apps'; -import WarningIcon from '@material-ui/icons/Warning'; + CardBody, + CardFooter, + Container, + Flex, + FullPage, + Text, + VisuallyHidden, +} from '@backstage/ui'; import { - Content, - EmptyState, - Header, - Page, - Progress, - ResponseErrorPanel, -} from '@backstage/core-components'; + RiAppsLine, + RiCheckboxCircleLine, + RiCloseCircleLine, +} from '@remixicon/react'; import { useConsentSession } from './useConsentSession'; import { configApiRef, useApi } from '@backstage/frontend-plugin-api'; +import styles from './ConsentPage.module.css'; -const useStyles = makeStyles(theme => ({ - authCard: { - maxWidth: 600, - margin: '0 auto', - marginTop: theme.spacing(4), - }, - appHeader: { - display: 'flex', - alignItems: 'center', - marginBottom: theme.spacing(2), - }, - appIcon: { - marginRight: theme.spacing(2), - fontSize: 40, - }, - appName: { - fontSize: '1.5rem', - fontWeight: 'bold', - }, - securityWarning: { - margin: theme.spacing(2, 0), - }, - buttonContainer: { - display: 'flex', - justifyContent: 'space-between', - gap: theme.spacing(2), - padding: theme.spacing(2), - }, - callbackUrl: { - fontFamily: 'monospace', - backgroundColor: theme.palette.background.default, - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - wordBreak: 'break-all', - fontSize: '0.875rem', - }, - scopeList: { - backgroundColor: theme.palette.background.default, - borderRadius: theme.shape.borderRadius, - padding: theme.spacing(1), - }, -})); - -const ConsentPageLayout = ({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) => ( - -
- {children} - +const ConsentPageLayout = ({ children }: { children: React.ReactNode }) => ( + + +

Authorization

+
+ {children} +
); export const ConsentPage = () => { - const classes = useStyles(); const { sessionId } = useParams<{ sessionId: string }>(); const { state, handleAction } = useConsentSession({ sessionId }); const configApi = useApi(configApiRef); @@ -106,9 +54,10 @@ export const ConsentPage = () => { if (!sessionId) { return ( - - + @@ -118,57 +67,62 @@ export const ConsentPage = () => { if (state.status === 'loading') { return ( - - - - + + ); } if (state.status === 'error') { return ( - - + + ); } if (state.status === 'completed') { return ( - - - - + + + + {state.action === 'approve' ? ( - ) : ( - )} - + {state.action === 'approve' ? 'Authorization Approved' : 'Authorization Denied'} - - + + {state.action === 'approve' ? `You have successfully authorized the application to access your ${appTitle} account.` : `You have denied the application access to your ${appTitle} account.`} - - + + Redirecting to the application... - - - + + + ); @@ -179,70 +133,67 @@ export const ConsentPage = () => { const appName = session.clientName ?? session.clientId; return ( - - - - - - - {appName} - - wants to access your {appTitle} account - + + + + + + + + + {appName} + + + wants to access your {appTitle} account + + - - +
- } - className={classes.securityWarning} - > - - Security Notice: By authorizing this application, - you are granting it access to your {appTitle} account. The - application will receive an access token that allows it to act on - your behalf. - - - - Callback URL: - - {session.redirectUri} - - + + By authorizing this application, you are granting it access to + your {appTitle} account. The application will receive an + access token that allows it to act on your behalf. +
+ {session.redirectUri} +
+ + } + /> - - + Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust. - - -
+ + + - - - - + + + + + +
); diff --git a/plugins/catalog-backend-module-gitea/README.md b/plugins/catalog-backend-module-gitea/README.md index 6b93c9c90a..12c9ae923a 100644 --- a/plugins/catalog-backend-module-gitea/README.md +++ b/plugins/catalog-backend-module-gitea/README.md @@ -4,5 +4,5 @@ This is an extension module to the plugin-catalog-backend plugin, providing exte ## Getting started -See [Backstage documentation](https://backstage.io/docs/integrations/gitea/discovery.md) +See [Backstage documentation](https://backstage.io/docs/integrations/gitea/discovery) for details on how to install and configure the plugin. diff --git a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.test.ts b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.test.ts index 486fb4b8ae..8b74e19755 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.test.ts +++ b/plugins/catalog-backend-module-incremental-ingestion/src/engine/IncrementalIngestionEngine.test.ts @@ -18,7 +18,15 @@ import { IncrementalIngestionEngine } from './IncrementalIngestionEngine'; import { IterationEngineOptions } from '../types'; import { performance } from 'node:perf_hooks'; -jest.setTimeout(60_000); +jest.mock('node:perf_hooks', () => ({ + performance: { + now: jest.fn(), + }, +})); + +const mockPerformanceNow = performance.now as jest.MockedFunction< + typeof performance.now +>; describe('IncrementalIngestionEngine - Burst Length', () => { const createMockProvider = () => ({ @@ -50,6 +58,10 @@ describe('IncrementalIngestionEngine - Burst Length', () => { child: jest.fn().mockReturnThis(), } as any); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should respect burst length and stop burst when time limit exceeded', async () => { const mockProvider = createMockProvider(); const mockManager = createMockManager(); @@ -60,7 +72,7 @@ describe('IncrementalIngestionEngine - Burst Length', () => { provider: mockProvider, manager: mockManager, connection: mockConnection, - burstLength: { milliseconds: 100 }, // Short burst length for testing + burstLength: { milliseconds: 100 }, restLength: { minutes: 1 }, logger: mockLogger, ready: Promise.resolve(), @@ -69,16 +81,17 @@ describe('IncrementalIngestionEngine - Burst Length', () => { const engine = new IncrementalIngestionEngine(options); let callCount = 0; + // Simulate time advancing: start at 1000, each call advances 40ms + let currentTime = 1000; + mockPerformanceNow.mockImplementation(() => currentTime); + mockProvider.around.mockImplementation(async fn => { await fn({}); }); - // Mock provider.next to return multiple batches that never complete - // Each call takes some time to simulate real processing mockProvider.next.mockImplementation(async () => { callCount++; - // Add a small delay to ensure we exceed burst length - await new Promise(resolve => setTimeout(resolve, 30)); + currentTime += 40; return { done: false, entities: [ @@ -94,19 +107,15 @@ describe('IncrementalIngestionEngine - Burst Length', () => { }); const signal = new AbortController().signal; - const start = performance.now(); const result = await engine.ingestOneBurst('test-ingestion', signal); - const duration = performance.now() - start; - - // Verify that the burst was stopped due to time limit, not completion + // Call 1: time=1040, elapsed=40 < 100 → continue + // Call 2: time=1080, elapsed=80 < 100 → continue + // Call 3: time=1120, elapsed=120 > 100 → stop expect(result).toBe(false); - expect(duration).toBeGreaterThanOrEqual(100); - expect(duration).toBeLessThan(200); - expect(mockProvider.next).toHaveBeenCalledTimes(callCount); - - expect(callCount).toBeGreaterThan(1); + expect(mockProvider.next).toHaveBeenCalledTimes(3); + expect(callCount).toBe(3); }); it('should complete burst normally when provider returns done before burst length', async () => { @@ -127,11 +136,13 @@ describe('IncrementalIngestionEngine - Burst Length', () => { const engine = new IncrementalIngestionEngine(options); + const currentTime = 1000; + mockPerformanceNow.mockImplementation(() => currentTime); + mockProvider.around.mockImplementation(async fn => { await fn({}); }); - // Mock provider.next to return done after first call mockProvider.next.mockResolvedValueOnce({ done: true, entities: [ @@ -146,13 +157,10 @@ describe('IncrementalIngestionEngine - Burst Length', () => { }); const signal = new AbortController().signal; - const start = performance.now(); const result = await engine.ingestOneBurst('test-ingestion', signal); - const duration = performance.now() - start; expect(result).toBe(true); expect(mockProvider.next).toHaveBeenCalledTimes(1); - expect(duration).toBeLessThan(100); // Should complete quickly since provider returns done immediately }); it('should stop burst when time limit is reached', async () => { @@ -174,13 +182,17 @@ describe('IncrementalIngestionEngine - Burst Length', () => { const engine = new IncrementalIngestionEngine(options); let callCount = 0; + // Simulate time advancing: start at 1000, each call advances 30ms + let currentTime = 1000; + mockPerformanceNow.mockImplementation(() => currentTime); + mockProvider.around.mockImplementation(async fn => { await fn({}); }); mockProvider.next.mockImplementation(async () => { callCount++; - await new Promise(resolve => setTimeout(resolve, 30)); + currentTime += 30; return { done: false, entities: [ @@ -196,16 +208,14 @@ describe('IncrementalIngestionEngine - Burst Length', () => { }); const signal = new AbortController().signal; - const start = performance.now(); const result = await engine.ingestOneBurst('test-ingestion', signal); - const duration = performance.now() - start; - + // Call 1: time=1030, elapsed=30 < 80 → continue + // Call 2: time=1060, elapsed=60 < 80 → continue + // Call 3: time=1090, elapsed=90 > 80 → stop expect(result).toBe(false); expect(mockProvider.next).toHaveBeenCalledTimes(3); expect(callCount).toBe(3); - expect(duration).toBeGreaterThanOrEqual(90); - expect(duration).toBeLessThan(120); }); }); diff --git a/plugins/catalog-backend/migrations/20260214000000_search_fk_final_entities.js b/plugins/catalog-backend/migrations/20260214000000_search_fk_final_entities.js index ed26716ca9..4d43baf9a8 100644 --- a/plugins/catalog-backend/migrations/20260214000000_search_fk_final_entities.js +++ b/plugins/catalog-backend/migrations/20260214000000_search_fk_final_entities.js @@ -16,54 +16,238 @@ // @ts-check +const BATCH_SIZE = 10000; + +/** + * Batch-deletes orphaned search rows whose entity_id doesn't exist in the + * given reference table. Processes in chunks to avoid long locks. + * + * @param {import('knex').Knex} knex + * @param {string} refTable - The table to check entity_id against + */ +async function batchDeleteOrphansPg(knex, refTable) { + for (;;) { + const deleted = await knex.raw(` + DELETE FROM "search" + WHERE ctid IN ( + SELECT s.ctid FROM "search" s + LEFT JOIN "${refTable}" r ON s."entity_id" = r."entity_id" + WHERE r."entity_id" IS NULL + AND s."entity_id" IS NOT NULL + LIMIT ${BATCH_SIZE} + ) + `); + if (deleted.rowCount === 0) { + break; + } + } +} + +/** + * @param {import('knex').Knex} knex + * @param {string} refTable + */ +async function batchDeleteOrphansMysql(knex, refTable) { + for (;;) { + const [orphanIds] = await knex.raw(` + SELECT DISTINCT s.\`entity_id\` FROM \`search\` s + LEFT JOIN \`${refTable}\` r ON s.\`entity_id\` = r.\`entity_id\` + WHERE r.\`entity_id\` IS NULL + AND s.\`entity_id\` IS NOT NULL + LIMIT ${BATCH_SIZE} + `); + if (orphanIds.length === 0) { + break; + } + const ids = orphanIds.map( + (/** @type {{ entity_id: string }} */ r) => r.entity_id, + ); + await knex('search').whereIn('entity_id', ids).delete(); + } +} + /** * Changes the search table's foreign key from refresh_state(entity_id) * to final_entities(entity_id). This allows search entries to reference * final entities directly, with CASCADE delete when entities are removed. * + * On PostgreSQL, the migration first switches the foreign key to point at + * final_entities using a single ALTER TABLE with a NOT VALID constraint, + * then batch-deletes any pre-existing orphaned rows outside of DDL, and + * finally VALIDATEs the constraint to keep the AccessExclusiveLock window + * as short as possible. + * + * On MySQL, the migration batch-deletes orphaned rows in chunks around the + * foreign key change to reduce lock time on large tables. + * * @param {import('knex').Knex} knex */ exports.up = async function up(knex) { - // Step 1: Drop the old foreign key constraint (to refresh_state) - await knex.schema.alterTable('search', table => { - table.dropForeign(['entity_id']); - }); + const client = knex.client.config.client; - // Step 2: Delete orphaned rows where entity_id doesn't exist in final_entities - await knex('search') - .whereNotIn('entity_id', knex('final_entities').select('entity_id')) - .delete(); + if (client.includes('pg')) { + // Drop old FK and immediately add the new one as NOT VALID in a single + // ALTER TABLE statement. This prevents new orphan rows from being + // inserted while we clean up existing ones, and eliminates any window + // where no FK exists at all. + await knex.raw(` + ALTER TABLE "search" + DROP CONSTRAINT IF EXISTS "search_entity_id_foreign", + ADD CONSTRAINT "search_entity_id_foreign" + FOREIGN KEY ("entity_id") REFERENCES "final_entities"("entity_id") + ON DELETE CASCADE + NOT VALID + `); - // Step 3: Add new FK to final_entities(entity_id) with CASCADE - await knex.schema.alterTable('search', table => { - table - .foreign('entity_id') - .references('entity_id') - .inTable('final_entities') - .onDelete('CASCADE'); - }); + // Batch-delete orphaned rows that existed before the NOT VALID FK was + // added. This runs outside any DDL lock, so it doesn't block reads. + await batchDeleteOrphansPg(knex, 'final_entities'); + + // Validate the FK separately. This only takes a + // ShareUpdateExclusiveLock, which does not block normal reads/writes + // (DML) but can still conflict with some DDL or maintenance operations. + await knex.raw( + `ALTER TABLE "search" VALIDATE CONSTRAINT "search_entity_id_foreign"`, + ); + } else if (client.includes('mysql')) { + // Batch-delete orphaned rows before DDL to reduce lock time. + await batchDeleteOrphansMysql(knex, 'final_entities'); + + // Swap the FK with retry logic. MySQL DDL causes implicit commits so + // DROP and ADD are never truly atomic. If new orphan rows sneak in + // between the DROP and ADD, the ADD will fail — we clean up and retry. + // The information_schema check makes the DROP idempotent so that + // re-runs after a partial failure don't crash. + const MAX_ATTEMPTS = 5; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const [fks] = await knex.raw(` + SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'search' + AND CONSTRAINT_NAME = 'search_entity_id_foreign' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + `); + if (fks.length > 0) { + await knex.schema.alterTable('search', table => { + table.dropForeign(['entity_id']); + }); + } + + await batchDeleteOrphansMysql(knex, 'final_entities'); + + try { + await knex.schema.alterTable('search', table => { + table + .foreign('entity_id') + .references('entity_id') + .inTable('final_entities') + .onDelete('CASCADE'); + }); + break; + } catch (e) { + if (attempt === MAX_ATTEMPTS) throw e; + } + } + } else { + // SQLite: wrap in an explicit transaction since the global transaction + // wrapper is disabled for this migration. + await knex.transaction(async trx => { + await trx.schema.alterTable('search', table => { + table.dropForeign(['entity_id']); + }); + + await trx('search') + .whereNotIn('entity_id', trx('final_entities').select('entity_id')) + .delete(); + + await trx.schema.alterTable('search', table => { + table + .foreign('entity_id') + .references('entity_id') + .inTable('final_entities') + .onDelete('CASCADE'); + }); + }); + } }; /** * @param {import('knex').Knex} knex */ exports.down = async function down(knex) { - // Step 1: Drop FK to final_entities - await knex.schema.alterTable('search', table => { - table.dropForeign(['entity_id']); - }); + const client = knex.client.config.client; - // Step 2: Delete orphaned rows where entity_id doesn't exist in refresh_state - await knex('search') - .whereNotIn('entity_id', knex('refresh_state').select('entity_id')) - .delete(); + if (client.includes('pg')) { + await knex.raw(` + ALTER TABLE "search" + DROP CONSTRAINT IF EXISTS "search_entity_id_foreign", + ADD CONSTRAINT "search_entity_id_foreign" + FOREIGN KEY ("entity_id") REFERENCES "refresh_state"("entity_id") + ON DELETE CASCADE + NOT VALID + `); - // Step 3: Add back FK to refresh_state(entity_id) with CASCADE - await knex.schema.alterTable('search', table => { - table - .foreign('entity_id') - .references('entity_id') - .inTable('refresh_state') - .onDelete('CASCADE'); - }); + await batchDeleteOrphansPg(knex, 'refresh_state'); + + await knex.raw( + `ALTER TABLE "search" VALIDATE CONSTRAINT "search_entity_id_foreign"`, + ); + } else if (client.includes('mysql')) { + await batchDeleteOrphansMysql(knex, 'refresh_state'); + + const MAX_ATTEMPTS = 5; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const [fks] = await knex.raw(` + SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'search' + AND CONSTRAINT_NAME = 'search_entity_id_foreign' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + `); + if (fks.length > 0) { + await knex.schema.alterTable('search', table => { + table.dropForeign(['entity_id']); + }); + } + + await batchDeleteOrphansMysql(knex, 'refresh_state'); + + try { + await knex.schema.alterTable('search', table => { + table + .foreign('entity_id') + .references('entity_id') + .inTable('refresh_state') + .onDelete('CASCADE'); + }); + break; + } catch (e) { + if (attempt === MAX_ATTEMPTS) throw e; + } + } + } else { + await knex.transaction(async trx => { + await trx.schema.alterTable('search', table => { + table.dropForeign(['entity_id']); + }); + + await trx('search') + .whereNotIn('entity_id', trx('refresh_state').select('entity_id')) + .delete(); + + await trx.schema.alterTable('search', table => { + table + .foreign('entity_id') + .references('entity_id') + .inTable('refresh_state') + .onDelete('CASCADE'); + }); + }); + } +}; + +// Disable the default transaction wrapper so the batched deletes run +// outside of the DDL transaction that holds AccessExclusiveLock. +exports.config = { + transaction: false, }; diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 38cd145421..7fda0ac0f1 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -525,94 +525,96 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } async removeEntityByUid(uid: string): Promise { - const dbConfig = this.database.client.config; + const relationPeerRefs = await this.database.transaction(async tx => { + const dbConfig = tx.client.config; - // Clear the hashed state of the immediate parents of the deleted entity. - // This makes sure that when they get reprocessed, their output is written - // down again. The reason for wanting to do this, is that if the user - // deletes entities that ARE still emitted by the parent, the parent - // processing will still generate the same output hash as always, which - // means it'll never try to write down the children again (it assumes that - // they already exist). This means that without the code below, the database - // never "heals" from accidental deletes. - if (dbConfig.client.includes('mysql')) { - // MySQL doesn't support the syntax we need to do this in a single query, - // http://dev.mysql.com/doc/refman/5.6/en/update.html - const results = await this.database('refresh_state') - .select('entity_id') - .whereIn('entity_ref', function parents(builder) { - return builder - .from('refresh_state') - .innerJoin( - 'refresh_state_references', - { - 'refresh_state_references.target_entity_ref': - 'refresh_state.entity_ref', - }, - ) - .where('refresh_state.entity_id', '=', uid) - .select('refresh_state_references.source_entity_ref'); - }); - await this.database('refresh_state') - .update({ - result_hash: 'child-was-deleted', - next_update_at: this.database.fn.now(), - }) - .whereIn( - 'entity_id', - results.map(key => key.entity_id), - ); - } else { - await this.database('refresh_state') - .update({ - result_hash: 'child-was-deleted', - next_update_at: this.database.fn.now(), - }) - .whereIn('entity_ref', function parents(builder) { - return builder - .from('refresh_state') - .innerJoin( - 'refresh_state_references', - { - 'refresh_state_references.target_entity_ref': - 'refresh_state.entity_ref', - }, - ) - .where('refresh_state.entity_id', '=', uid) - .select('refresh_state_references.source_entity_ref'); - }); - } - - // Stitch the entities that the deleted one had relations to. If we do not - // do this, the entities in the other end of the relations will still look - // like they have a relation to the entity that was deleted, despite not - // having any corresponding rows in the relations table. - const relationPeers = await this.database - .from('relations') - .innerJoin('refresh_state', { - 'refresh_state.entity_ref': 'relations.target_entity_ref', - }) - .where('relations.originating_entity_id', '=', uid) - .andWhere('refresh_state.entity_id', '!=', uid) - .select({ ref: 'relations.target_entity_ref' }) - .union(other => - other - .from('relations') - .innerJoin('refresh_state', { - 'refresh_state.entity_ref': 'relations.source_entity_ref', + // Clear the hashed state of the immediate parents of the deleted entity. + // This makes sure that when they get reprocessed, their output is written + // down again. The reason for wanting to do this, is that if the user + // deletes entities that ARE still emitted by the parent, the parent + // processing will still generate the same output hash as always, which + // means it'll never try to write down the children again (it assumes that + // they already exist). This means that without the code below, the database + // never "heals" from accidental deletes. + if (dbConfig.client.includes('mysql')) { + // MySQL doesn't support the syntax we need to do this in a single query, + // http://dev.mysql.com/doc/refman/5.6/en/update.html + const results = await tx('refresh_state') + .select('entity_id') + .whereIn('entity_ref', function parents(builder) { + return builder + .from('refresh_state') + .innerJoin( + 'refresh_state_references', + { + 'refresh_state_references.target_entity_ref': + 'refresh_state.entity_ref', + }, + ) + .where('refresh_state.entity_id', '=', uid) + .select('refresh_state_references.source_entity_ref'); + }); + await tx('refresh_state') + .update({ + result_hash: 'child-was-deleted', + next_update_at: tx.fn.now(), }) - .where('relations.originating_entity_id', '=', uid) - .andWhere('refresh_state.entity_id', '!=', uid) - .select({ ref: 'relations.source_entity_ref' }), - ); + .whereIn( + 'entity_id', + results.map(key => key.entity_id), + ); + } else { + await tx('refresh_state') + .update({ + result_hash: 'child-was-deleted', + next_update_at: tx.fn.now(), + }) + .whereIn('entity_ref', function parents(builder) { + return builder + .from('refresh_state') + .innerJoin( + 'refresh_state_references', + { + 'refresh_state_references.target_entity_ref': + 'refresh_state.entity_ref', + }, + ) + .where('refresh_state.entity_id', '=', uid) + .select('refresh_state_references.source_entity_ref'); + }); + } - await this.database('refresh_state') - .where('entity_id', uid) - .delete(); + const relationPeers = await tx + .from('relations') + .innerJoin('refresh_state', { + 'refresh_state.entity_ref': 'relations.target_entity_ref', + }) + .where('relations.originating_entity_id', '=', uid) + .andWhere('refresh_state.entity_id', '!=', uid) + .select({ ref: 'relations.target_entity_ref' }) + .union(other => + other + .from('relations') + .innerJoin('refresh_state', { + 'refresh_state.entity_ref': 'relations.source_entity_ref', + }) + .where('relations.originating_entity_id', '=', uid) + .andWhere('refresh_state.entity_id', '!=', uid) + .select({ ref: 'relations.source_entity_ref' }), + ); - await this.stitcher.stitch({ - entityRefs: new Set(relationPeers.map(p => p.ref)), + await tx('refresh_state') + .where('entity_id', uid) + .delete(); + + return new Set(relationPeers.map(p => p.ref)); }); + + if (relationPeerRefs.size > 0) { + await this.stitcher.stitch({ + entityRefs: relationPeerRefs, + }); + } } async entityAncestry(rootRef: string): Promise { diff --git a/plugins/catalog-backend/src/tests/migrations.test.ts b/plugins/catalog-backend/src/tests/migrations.test.ts index 4a469ab07e..24a23b2888 100644 --- a/plugins/catalog-backend/src/tests/migrations.test.ts +++ b/plugins/catalog-backend/src/tests/migrations.test.ts @@ -784,67 +784,96 @@ describe('migrations', () => { value: 'my-api', original_value: 'my-api', }, + // NULL entity_id row should survive the migration + { + entity_id: null, + key: 'global', + value: 'setting', + original_value: 'setting', + }, ]); // Verify initial state const preMigrationCount = await knex('search').count('* as count'); - expect(Number(preMigrationCount[0].count)).toBe(6); + expect(Number(preMigrationCount[0].count)).toBe(7); // Run the migration await migrateUpOnce(knex); - // Verify orphaned rows (id3) were deleted since id3 is not in final_entities - const postMigrationRows = await knex('search').orderBy([ - 'entity_id', - 'key', - ]); - expect(postMigrationRows).toEqual([ - { - entity_id: 'id1', + // Verify orphaned rows (id3) were deleted, but NULL entity_id row survived + const postMigrationRows = await knex('search'); + expect(postMigrationRows).toHaveLength(5); + expect(postMigrationRows).toEqual( + expect.arrayContaining([ + { + entity_id: null, + key: 'global', + value: 'setting', + original_value: 'setting', + }, + { + entity_id: 'id1', + key: 'kind', + value: 'component', + original_value: 'Component', + }, + { + entity_id: 'id1', + key: 'metadata.name', + value: 'service-a', + original_value: 'service-a', + }, + { + entity_id: 'id2', + key: 'kind', + value: 'component', + original_value: 'Component', + }, + { + entity_id: 'id2', + key: 'metadata.name', + value: 'service-b', + original_value: 'service-b', + }, + ]), + ); + + // Verify FK is enforced: inserting with a nonexistent entity_id should be rejected + await expect( + knex('search').insert({ + entity_id: 'nonexistent', key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id1', - key: 'metadata.name', - value: 'service-a', - original_value: 'service-a', - }, - { - entity_id: 'id2', - key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id2', - key: 'metadata.name', - value: 'service-b', - original_value: 'service-b', - }, - ]); + value: 'test', + original_value: 'test', + }), + ).rejects.toEqual(expect.anything()); // Verify FK cascade works: deleting from final_entities should cascade to search await knex('final_entities').where({ entity_id: 'id1' }).delete(); - const searchAfterDelete = await knex('search').orderBy([ - 'entity_id', - 'key', - ]); - expect(searchAfterDelete).toEqual([ - { - entity_id: 'id2', - key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id2', - key: 'metadata.name', - value: 'service-b', - original_value: 'service-b', - }, - ]); + const searchAfterDelete = await knex('search'); + expect(searchAfterDelete).toHaveLength(3); + expect(searchAfterDelete).toEqual( + expect.arrayContaining([ + { + entity_id: null, + key: 'global', + value: 'setting', + original_value: 'setting', + }, + { + entity_id: 'id2', + key: 'kind', + value: 'component', + original_value: 'Component', + }, + { + entity_id: 'id2', + key: 'metadata.name', + value: 'service-b', + original_value: 'service-b', + }, + ]), + ); // Restore id1 for down migration test await knex('final_entities').insert({ @@ -863,53 +892,49 @@ describe('migrations', () => { }, ]); + // Delete id1 from refresh_state so it becomes an orphan for the down + // migration (exists in final_entities but not in refresh_state) + await knex('refresh_state').where({ entity_id: 'id1' }).delete(); + // Run the down migration await migrateDownOnce(knex); - // Verify data is still intact after down migration - const revertedSearchRows = await knex('search').orderBy([ - 'entity_id', - 'key', - ]); - expect(revertedSearchRows).toEqual([ - { - entity_id: 'id1', - key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id2', - key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id2', - key: 'metadata.name', - value: 'service-b', - original_value: 'service-b', - }, - ]); + // Verify id1 search rows were cleaned up (orphan for refresh_state FK), + // while id2 and the NULL entity_id row survived + const revertedSearchRows = await knex('search'); + expect(revertedSearchRows).toHaveLength(3); + expect(revertedSearchRows).toEqual( + expect.arrayContaining([ + { + entity_id: null, + key: 'global', + value: 'setting', + original_value: 'setting', + }, + { + entity_id: 'id2', + key: 'kind', + value: 'component', + original_value: 'Component', + }, + { + entity_id: 'id2', + key: 'metadata.name', + value: 'service-b', + original_value: 'service-b', + }, + ]), + ); // Verify FK is back to refresh_state: deleting from refresh_state should cascade - await knex('refresh_state').where({ entity_id: 'id1' }).delete(); - const afterRefreshStateDelete = await knex('search').orderBy([ - 'entity_id', - 'key', - ]); + await knex('refresh_state').where({ entity_id: 'id2' }).delete(); + const afterRefreshStateDelete = await knex('search'); expect(afterRefreshStateDelete).toEqual([ { - entity_id: 'id2', - key: 'kind', - value: 'component', - original_value: 'Component', - }, - { - entity_id: 'id2', - key: 'metadata.name', - value: 'service-b', - original_value: 'service-b', + entity_id: null, + key: 'global', + value: 'setting', + original_value: 'setting', }, ]); diff --git a/plugins/catalog-graph/README-alpha.md b/plugins/catalog-graph/README-alpha.md index 6242e9cc39..90481caca6 100644 --- a/plugins/catalog-graph/README-alpha.md +++ b/plugins/catalog-graph/README-alpha.md @@ -1,8 +1,6 @@ -# Catalog Graph +# Catalog Graph - Extension Reference -> [!WARNING] -> This documentation is made for those using the experimental new Frontend system. -> If you are not using the new frontend system, please go [here](./README.md). +This page contains detailed documentation for all extensions provided by the `@backstage/plugin-catalog-graph` plugin. For general information about the plugin, see the [README](./README.md). The Catalog graph plugin helps you to visualize the relations between entities, like ownership, grouping or API relationships. It comes with these features: diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md index b411d4d868..058fc28164 100644 --- a/plugins/catalog-graph/README.md +++ b/plugins/catalog-graph/README.md @@ -1,8 +1,5 @@ # catalog-graph -> Disclaimer: -> If you are looking for documentation on the experimental new frontend system support, please go [here](./README-alpha.md). - Welcome to the catalog graph plugin! The catalog graph visualizes the relations between entities, like ownership, grouping or API relationships. @@ -23,58 +20,25 @@ The plugin comes with these features: - `EntityRelationsGraph`: A react component that can be used to build own customized entity relation graphs. -## Usage +## Installation -To use the catalog graph plugin, you have to add some things to your Backstage app: +```sh +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-catalog-graph +``` -1. Add a dependency to your `packages/app/package.json`: - ```sh - # From your Backstage root directory - yarn --cwd packages/app add @backstage/plugin-catalog-graph - ``` -2. Add the `CatalogGraphPage` to your `packages/app/src/App.tsx`: +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). - ```typescript - - … - } />… - - ``` +To enable the entity relations graph card on the catalog entity page, add the following configuration: - You can configure the page to open with some initial filters: +```yaml +# app-config.yaml +app: + extensions: + - entity-card:catalog-graph/relations +``` - ```typescript - - } - /> - ``` - -3. Bind the external routes of the `catalogGraphPlugin` in your `packages/app/src/App.tsx`: - - ```typescript - bindRoutes({ bind }) { - … - bind(catalogGraphPlugin.externalRoutes, { - catalogEntity: catalogPlugin.routes.catalogEntity, - }); - … - } - ``` - -4. Add `EntityCatalogGraphCard` to any entity page that you want in your `packages/app/src/components/catalog/EntityPage.tsx`: - - ```typescript - - - - ``` +For the full list of available extensions and their configuration options, see the [README-alpha.md](./README-alpha.md). ### Customizing the UI @@ -177,6 +141,54 @@ import { }), ``` +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + +1. Add the `CatalogGraphPage` to your `packages/app/src/App.tsx`: + + ```typescript + + … + } />… + + ``` + + You can configure the page to open with some initial filters: + + ```typescript + + } + /> + ``` + +2. Bind the external routes of the `catalogGraphPlugin` in your `packages/app/src/App.tsx`: + + ```typescript + bindRoutes({ bind }) { + … + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); + … + } + ``` + +3. Add `EntityCatalogGraphCard` to any entity page that you want in your `packages/app/src/components/catalog/EntityPage.tsx`: + + ```typescript + + + + ``` + ## Development Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin. diff --git a/plugins/catalog-import/README.md b/plugins/catalog-import/README.md index d4ede8af7b..efb7c84fa3 100644 --- a/plugins/catalog-import/README.md +++ b/plugins/catalog-import/README.md @@ -15,22 +15,14 @@ Some features are not yet available for all supported Git providers. ## Getting Started -1. Install the Catalog Import Plugin: +Install the Catalog Import Plugin: ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-catalog-import ``` -2. Add the `CatalogImportPage` extension to the app: - -```tsx -// packages/app/src/App.tsx - -import { CatalogImportPage } from '@backstage/plugin-catalog-import'; - -} />; -``` +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). ## Customizations @@ -104,6 +96,22 @@ Following React components accept optional props for providing custom example en /> ``` +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the +plugin into your app as outlined in this section. If you are on the new frontend +system, you can skip this. + +Add the `CatalogImportPage` extension to the app: + +```tsx +// packages/app/src/App.tsx + +import { CatalogImportPage } from '@backstage/plugin-catalog-import'; + +} />; +``` + ## Development Use `yarn start` to run a [development version](./dev/index.tsx) of the plugin that can be used to validate each flow with mocked data. diff --git a/plugins/catalog-unprocessed-entities/README.md b/plugins/catalog-unprocessed-entities/README.md index b39a4a7ad4..fc20168b80 100644 --- a/plugins/catalog-unprocessed-entities/README.md +++ b/plugins/catalog-unprocessed-entities/README.md @@ -32,7 +32,26 @@ Requires the `@backstage/plugin-catalog-backend-module-unprocessed` module to be yarn --cwd packages/app add @backstage/plugin-catalog-unprocessed-entities ``` -Import into your `App.tsx` and include into the `` component: +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). + +You can optionally add unprocessed entities as a tab in DevTools through configuration: + +```yaml +app: + extensions: + # Enable the catalog-unprocessed-entities tab in devtools + - devtools-content:catalog-unprocessed-entities: true + # Disable the catalog-unprocessed-entities element outside devtools including the sidebar + - page:catalog-unprocessed-entities: false +``` + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the +plugin into your app as outlined in this section. If you are on the new frontend +system, you can skip this. + +Import it into your `App.tsx` and include it in the `` component: ```tsx title="packages/app/src/App.tsx" import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities'; @@ -44,44 +63,6 @@ import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unproc />; ``` -### Integrating with the New Frontend System - -Follow this section if you are using Backstage's [new frontend system](https://backstage.io/docs/frontend-system/). - -Import `catalogUnprocessedEntitiesPlugin` in your `App.tsx` and add it to your app's `features` array: - -```typescript -import catalogUnprocessedEntitiesPlugin from '@backstage/plugin-catalog-unprocessed-entities'; -import { unprocessedEntitiesDevToolsContent } from '@backstage/plugin-catalog-unprocessed-entities/alpha'; - -// Optionally add unprocessed entities route to devtools -const devtoolsPluginUnprocessed = createFrontendModule({ - pluginId: 'catalog-unprocessed-entities', - extensions: [unprocessedEntitiesDevToolsContent], -}); - -export const app = createApp({ - features: [ - // ... - catalogUnprocessedEntitiesPlugin, - - // Optionally add unprocessed entities route to devtools - devtoolsPluginUnprocessed, - devtoolsPlugin, // devtools plugin needs to be added too, if autodiscover is disabled - // ... - ], -}); -``` - -```yaml -app: - extensions: - # Enable the catalog-unprocessed-entities tab in devtools - - devtools-content:catalog-unprocessed-entities: true - # Disable the catalog-unprocessed-entities element outside devtools including the sidebar - - page:catalog-unprocessed-entities: false -``` - ## Customization If you want to use the provided endpoints in a different way, you can use the ApiRef doing the following: diff --git a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx index 8fd614c886..d2987f3197 100644 --- a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx +++ b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx @@ -69,7 +69,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({ export default createFrontendPlugin({ pluginId: 'catalog-unprocessed-entities', title: 'Unprocessed Entities', - icon: , + icon: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md index e8de1118ac..af8c82b35c 100644 --- a/plugins/catalog/README.md +++ b/plugins/catalog/README.md @@ -15,13 +15,17 @@ To check if you already have the package, look under `@backstage/plugin-catalog`. The instructions below walk through restoring the plugin, if you previously removed it. -### Install the package - ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-catalog ``` +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + ### Add the plugin to your `packages/app` Add the two pages that the catalog plugin provides to your app. You can choose diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index ab71cf39dc..6d64c65b74 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -59,7 +59,7 @@ export const catalogPage = PageBlueprint.makeWithOverrides({ return originalFactory({ path: '/catalog', routeRef: rootRouteRef, - icon: , + icon: , title: 'Catalog', loader: async () => { const { BaseCatalogPage } = await import('../components/CatalogPage'); diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 8db026016a..243b93db9b 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -40,7 +40,7 @@ import contextMenuItems from './contextMenuItems'; export default createFrontendPlugin({ pluginId: 'catalog', title: 'Catalog', - icon: , + icon: , info: { packageJson: () => import('../../package.json'), }, diff --git a/plugins/devtools-common/src/alpha.ts b/plugins/devtools-common/src/alpha.ts index 984967c4f3..7abe4b1f5e 100644 --- a/plugins/devtools-common/src/alpha.ts +++ b/plugins/devtools-common/src/alpha.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { devToolsTaskSchedulerReadPermission, devToolsTaskSchedulerCreatePermission, diff --git a/plugins/devtools/README.md b/plugins/devtools/README.md index b23c35d4dd..3f9a3da747 100644 --- a/plugins/devtools/README.md +++ b/plugins/devtools/README.md @@ -66,42 +66,14 @@ You need to setup the [DevTools backend plugin](../devtools-backend/README.md) b ### Frontend -To setup the DevTools frontend you'll need to do the following steps: +Install the `@backstage/plugin-devtools` package in your frontend app: -1. First we need to add the `@backstage/plugin-devtools` package to your frontend app: +```sh +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-devtools +``` - ```sh - # From your Backstage root directory - yarn --cwd packages/app add @backstage/plugin-devtools - ``` - -2. Now open the `packages/app/src/App.tsx` file -3. Then after all the import statements add the following line: - - ```ts - import { DevToolsPage } from '@backstage/plugin-devtools'; - ``` - -4. In this same file just before the closing ``, this will be near the bottom of the file, add this line: - - ```ts - } /> - ``` - -5. Next open the `packages/app/src/components/Root/Root.tsx` file -6. We want to add this icon import after all the existing import statements: - - ```ts - import BuildIcon from '@material-ui/icons/Build'; - ``` - -7. Then add this line just after the `` line: - - ```ts - - ``` - -8. Now run `yarn start` from the root of your project and you should see the DevTools option show up just below Settings in your sidebar and clicking on it will get you to the [Info tab](#info) +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). ## Customizing @@ -166,8 +138,6 @@ You can also add tabs to show content from other plugins that fit well with the #### Catalog Unprocessed Entities Tab -##### New Frontend System - Create an extension and/or load a 3rd party extension to add additional tabs. ```shell @@ -197,7 +167,7 @@ const appFeature = createFrontendModule({ }); ``` -##### Old System +##### Old Frontend System Here's how to add the Catalog Unprocessed Entities tab: @@ -230,6 +200,38 @@ Here's how to add the Catalog Unprocessed Entities tab: 4. Now run `yarn start` and navigate to the DevTools you'll see a new tab for Unprocessed Entities +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + +1. Open the `packages/app/src/App.tsx` file +2. Then after all the import statements add the following line: + + ```ts + import { DevToolsPage } from '@backstage/plugin-devtools'; + ``` + +3. In this same file just before the closing ``, this will be near the bottom of the file, add this line: + + ```ts + } /> + ``` + +4. Next open the `packages/app/src/components/Root/Root.tsx` file +5. We want to add this icon import after all the existing import statements: + + ```ts + import BuildIcon from '@material-ui/icons/Build'; + ``` + +6. Then add this line just after the `` line: + + ```ts + + ``` + +7. Now run `yarn start` from the root of your project and you should see the DevTools option show up just below Settings in your sidebar and clicking on it will get you to the [Info tab](#info) + ## Permissions The DevTools plugin supports the [permissions framework](https://backstage.io/docs/permissions/overview), the following sections outline how you can use them with the assumption that you have the permissions framework setup and working. diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index b1cfa7027f..b991e34bf9 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -89,7 +89,7 @@ export const devToolsNavItem = NavItemBlueprint.make({ export default createFrontendPlugin({ pluginId: 'devtools', title: 'DevTools', - icon: , + icon: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/devtools/src/api/DevToolsApi.ts b/plugins/devtools/src/api/DevToolsApi.ts index 4fc579ee06..2aaad49aa8 100644 --- a/plugins/devtools/src/api/DevToolsApi.ts +++ b/plugins/devtools/src/api/DevToolsApi.ts @@ -38,4 +38,5 @@ export interface DevToolsApi { plugin: string, taskId: string, ): Promise; + cancelScheduledTask(plugin: string, taskId: string): Promise; } diff --git a/plugins/devtools/src/api/DevToolsClient.ts b/plugins/devtools/src/api/DevToolsClient.ts index 0577757838..b6d9d4b119 100644 --- a/plugins/devtools/src/api/DevToolsClient.ts +++ b/plugins/devtools/src/api/DevToolsClient.ts @@ -24,7 +24,7 @@ import { ScheduledTasks, TriggerScheduledTask, } from '@backstage/plugin-devtools-common/alpha'; -import { ResponseError } from '@backstage/errors'; +import { ResponseError, NotFoundError, ConflictError } from '@backstage/errors'; import { DevToolsApi } from './DevToolsApi'; export class DevToolsClient implements DevToolsApi { @@ -82,7 +82,31 @@ export class DevToolsClient implements DevToolsApi { throw await ResponseError.fromResponse(response); } - return response.json() as Promise; + return {}; + } + + public async cancelScheduledTask( + plugin: string, + taskId: string, + ): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl(plugin)}/`; + const url = new URL( + `.backstage/scheduler/v1/tasks/${encodeURIComponent(taskId)}/cancel`, + baseUrl, + ); + + const response = await this.fetchApi.fetch(url.toString(), { + method: 'POST', + }); + + if (!response.ok) { + if (response.status === 404) { + throw new NotFoundError(`Task ${taskId} not found`); + } else if (response.status === 409) { + throw new ConflictError(`Task ${taskId} is not running`); + } + throw await ResponseError.fromResponse(response); + } } public async getExternalDependencies(): Promise< diff --git a/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx index 7517cf8ea5..af86b9b237 100644 --- a/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx +++ b/plugins/devtools/src/components/Content/ScheduledTasksContent/ScheduledTasksContent.tsx @@ -29,10 +29,11 @@ import { TableColumn, } from '@backstage/core-components'; import Alert from '@material-ui/lab/Alert'; -import { useScheduledTasks, useTriggerScheduledTask } from '../../../hooks'; +import { useScheduledTasks, useScheduledTasksOperations } from '../../../hooks'; import { TaskApiTasksResponse } from '@backstage/plugin-devtools-common/alpha'; import { alertApiRef, configApiRef, useApi } from '@backstage/core-plugin-api'; import RefreshIcon from '@material-ui/icons/Refresh'; +import StopIcon from '@material-ui/icons/Stop'; import NightsStay from '@material-ui/icons/NightsStay'; import ErrorIcon from '@material-ui/icons/Error'; import BlockIcon from '@material-ui/icons/Block'; @@ -105,7 +106,7 @@ export const ScheduledTasksContent = () => { configApi.getOptionalStringArray('devTools.scheduledTasks.plugins') || []; const [selectedPlugin, setSelectedPlugin] = useState(plugins[0] || ''); const { scheduledTasks, loading, error } = useScheduledTasks(selectedPlugin); - const { triggerTask, isTriggering, triggerError } = useTriggerScheduledTask(); + const { triggerTask, cancelTask, isLoading } = useScheduledTasksOperations(); const [inputValue, setInputValue] = useState(''); @@ -209,28 +210,52 @@ export const ScheduledTasksContent = () => { permission={devToolsTaskSchedulerCreatePermission} errorPage={} > - - { - triggerTask(selectedPlugin, rowData.taskId); - if (triggerError) { - alertApi.post({ - message: `Error triggering task ${rowData.taskId}: ${error}`, - severity: 'error', - }); - } else { - alertApi.post({ - message: `Successfully triggered task ${rowData.taskId}`, - severity: 'success', - }); - } - }} - > - - - + + + { + try { + await triggerTask(selectedPlugin, rowData.taskId); + alertApi.post({ + message: `Successfully triggered task ${rowData.taskId}`, + severity: 'success', + }); + } catch (e) { + alertApi.post({ + message: `Error triggering task ${rowData.taskId}: ${e.message}`, + severity: 'error', + }); + } + }} + > + + + + + { + try { + await cancelTask(selectedPlugin, rowData.taskId); + alertApi.post({ + message: `Successfully cancelled task ${rowData.taskId}`, + severity: 'success', + }); + } catch (e) { + alertApi.post({ + message: `Error cancelling task ${rowData.taskId}: ${e.message}`, + severity: 'error', + }); + } + }} + > + + + + ), sorting: false, @@ -263,7 +288,7 @@ export const ScheduledTasksContent = () => { )} /> - {loading && } + {loading && !scheduledTasks && } {error && ( { )} - {!loading && !error && ( + {scheduledTasks && (
{ search: true, sorting: true, searchFieldAlignment: 'right', + padding: 'dense', }} columns={columns} data={scheduledTasks || []} diff --git a/plugins/devtools/src/hooks/index.ts b/plugins/devtools/src/hooks/index.ts index 105d03fb68..9d212bc3af 100644 --- a/plugins/devtools/src/hooks/index.ts +++ b/plugins/devtools/src/hooks/index.ts @@ -18,4 +18,4 @@ export { useConfig } from './useConfig'; export { useExternalDependencies } from './useExternalDependencies'; export { useInfo } from './useInfo'; export { useScheduledTasks } from './useScheduledTasks'; -export { useTriggerScheduledTask } from './useTriggerScheduledTask'; +export { useScheduledTasksOperations } from './useScheduledTasksOperations'; diff --git a/plugins/devtools/src/hooks/useTriggerScheduledTask.ts b/plugins/devtools/src/hooks/useScheduledTasksOperations.ts similarity index 66% rename from plugins/devtools/src/hooks/useTriggerScheduledTask.ts rename to plugins/devtools/src/hooks/useScheduledTasksOperations.ts index d26b7a8025..ff9d02b675 100644 --- a/plugins/devtools/src/hooks/useTriggerScheduledTask.ts +++ b/plugins/devtools/src/hooks/useScheduledTasksOperations.ts @@ -17,22 +17,40 @@ import { useState, useCallback } from 'react'; import { devToolsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; -export const useTriggerScheduledTask = () => { +export const useScheduledTasksOperations = () => { const api = useApi(devToolsApiRef); - const [isTriggering, setIsTriggering] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const triggerTask = useCallback( async (plugin: string, taskId: string) => { - setIsTriggering(true); + setIsLoading(true); setError(undefined); try { await api.triggerScheduledTask(plugin, taskId); } catch (e) { setError(e); + throw e; } finally { - setIsTriggering(false); + setIsLoading(false); + } + }, + [api], + ); + + const cancelTask = useCallback( + async (plugin: string, taskId: string) => { + setIsLoading(true); + setError(undefined); + + try { + await api.cancelScheduledTask(plugin, taskId); + } catch (e) { + setError(e); + throw e; + } finally { + setIsLoading(false); } }, [api], @@ -40,7 +58,8 @@ export const useTriggerScheduledTask = () => { return { triggerTask, - isTriggering, - triggerError: error?.message, + cancelTask, + isLoading, + error: error?.message, }; }; diff --git a/plugins/home/README.md b/plugins/home/README.md index 04d1b8c2ec..2c46530c3d 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -6,40 +6,19 @@ For App Integrators, the system is designed to be composable to give total freed ## Installation -If you have a standalone app (you didn't clone this repo), then do - ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-home ``` -## Getting started - -The home plugin supports both the new frontend system and the legacy system. - -### New Frontend System - -If you're using Backstage's new frontend system, add the plugin to your app: - -```ts -// packages/app/src/App.tsx -import homePlugin from '@backstage/plugin-home/alpha'; - -const app = createApp({ - features: [ - // ... other plugins - homePlugin, - // ... other plugins - ], -}); -``` +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). The plugin will automatically provide: - A homepage at `/home` with customizable widget grid - A "Home" navigation item in the sidebar -#### Creating Custom Homepage Layouts +## Creating Custom Homepage Layouts Use the `HomePageLayoutBlueprint` from `@backstage/plugin-home-react/alpha` to create custom homepage layouts. A layout receives the installed widgets and is @@ -86,7 +65,7 @@ const homeModule = createFrontendModule({ }); ``` -#### Visit Tracking (Optional) +## Visit Tracking (Optional) Visit tracking is an **optional feature** that must be explicitly enabled. When enabled, it provides intelligent storage fallbacks: @@ -112,12 +91,7 @@ app: ## Creating Homepage Widgets -Homepage widgets are React components that can be added to customizable home pages. The **key difference** between the new frontend system and legacy system is how these widget components are **registered and exported**: - -- **New Frontend System**: Use `HomePageWidgetBlueprint` to register widgets as extensions -- **Legacy System**: Use `createCardExtension` to export widgets as card components - -### New Frontend System +Homepage widgets are React components that can be added to customizable home pages. Create widgets using the `HomePageWidgetBlueprint`: @@ -156,26 +130,24 @@ const myWidget = HomePageWidgetBlueprint.make({ }); ``` -> **Example**: See [dev/index.tsx](dev/index.tsx) for a comprehensive example of creating multiple homepage widgets and layouts using the new frontend system. +> **Example**: See [dev/index.tsx](dev/index.tsx) for a comprehensive example of creating multiple homepage widgets and layouts. -### Legacy System - Widget Registration +## Contributing -In the legacy system, use the `createCardExtension` helper to create homepage widgets: +### Homepage Components -```tsx -import { createCardExtension } from '@backstage/plugin-home-react'; +We believe that people have great ideas for what makes a useful Home Page, and we want to make it easy for everyone to benefit from the effort you put in to create something cool for the Home Page. Therefore, a great way of contributing is by simply creating more Home Page Components that can then be used by everyone when composing their own Home Page. If they are tightly coupled to an existing plugin, it is recommended to allow them to live within that plugin, for convenience and to limit complex dependencies. On the other hand, if there's no clear plugin that the component is based on, it's also fine to contribute them into the [home plugin](/plugins/home/src/homePageComponents) -export const MyWidget = homePlugin.provide( - createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({ - title: 'My Custom Widget', - components: () => import('./homePageComponents/MyWidget'), - }), -); -``` +Additionally, the API is at a very early state, so contributing additional use cases may expose weaknesses in the current solution that we may iterate on to provide more flexibility and ease of use for those who wish to develop components for the Home Page. -The `createCardExtension` provides error boundary and lazy loading, and accepts generics for custom props that App Integrators can configure. +### Homepage Templates -## Legacy System Setup +We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). +If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example](/packages/app-legacy/src/components/home/templates/DefaultTemplate.stories.tsx) or [CustomizableTemplate storybook example](/packages/app-legacy/src/components/home/templates/CustomizableTemplate.stories.tsx) to create your own, and then open a PR with your suggestion. + +## Old Frontend System + +This section covers the home plugin setup and usage for apps that still use the old frontend system. ### Setting up the Home Page @@ -204,13 +176,26 @@ import { homePage } from './components/home/HomePage'; // ... ``` -### Creating Components (Legacy) +### Creating Widgets (Old Frontend System) -In the legacy system, homepage components can be regular React components or wrapped with `createCardExtension` for additional features like error boundaries and lazy loading. Components created with `createCardExtension` are exported as card components that can be composed into homepage layouts. +In the old frontend system, use the `createCardExtension` helper to create homepage widgets: -### Composing a Home Page (Legacy) +```tsx +import { createCardExtension } from '@backstage/plugin-home-react'; -In the legacy system, composing a Home Page is done by creating regular React components. Components created with `createCardExtension` are rendered like so: +export const MyWidget = homePlugin.provide( + createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({ + title: 'My Custom Widget', + components: () => import('./homePageComponents/MyWidget'), + }), +); +``` + +The `createCardExtension` provides error boundary and lazy loading, and accepts generics for custom props that App Integrators can configure. + +### Composing a Home Page (Old Frontend System) + +Composing a Home Page is done by creating regular React components. Components created with `createCardExtension` are rendered like so: ```tsx import Grid from '@material-ui/core/Grid'; @@ -227,7 +212,7 @@ export const homePage = ( Additionally, the App Integrator is provided an escape hatch in case the way the card is rendered does not fit their requirements. They may optionally pass the `Renderer`-prop, which will receive the `title`, `content` and optionally `actions`, `settings` and `contextProvider`, if they exist for the component. This allows the App Integrator to render the content in any way they want. -## Customizable home page +### Customizable Home Page (Old Frontend System) If you want to allow users to customize the components that are shown in the home page, you can use CustomHomePageGrid component. By adding the allowed components inside the grid, the user can add, configure, remove and move the components around in their @@ -259,7 +244,7 @@ export const homePage = ( > [!NOTE] > You can provide a title to the grid by passing it as a prop: ``. This will be displayed as a header above the grid layout. -### Creating Customizable Components +#### Creating Customizable Components (Old Frontend System) The custom home page can use the default components created by using the default `createCardExtension` method but if you want to add additional configuration like component size or settings, you can define those in the `layout` @@ -317,7 +302,7 @@ Available home page properties that are used for homepage widgets are: | `layout.height.maxRows` | integer | Maximum height of the widget (1-12) | | `settings.schema` | object | Customization settings of the widget, see below | -#### Widget Specific Settings +#### Widget Specific Settings (Old Frontend System) To define settings that the users can change for your component, you should define the `layout` and `settings` properties. The `settings.schema` object should follow @@ -369,7 +354,7 @@ Each widget has its own settings and the setting values are passed to the underl In case your `CardExtension` had `Settings` component defined, it will automatically disappear when you add the `settingsSchema` to the component data structure. -### Adding Default Layout +#### Adding Default Layout (Old Frontend System) You can set the default layout of the customizable home page by passing configuration to the `CustomHomepageGrid` component: @@ -391,7 +376,7 @@ const defaultConfig = [ ``` -## Page visit homepage component (HomePageTopVisited / HomePageRecentlyVisited) +### Page Visit Homepage Component (Old Frontend System) This component shows the homepage user a view for "Recently visited" or "Top visited". Being provided by the `` and `` component, see it in use on a homepage example below: @@ -505,11 +490,11 @@ home: In order to validate the config you can use `backstage/cli config:check` -### Customizing the VisitList +#### Customizing the VisitList (Old Frontend System) If you want more control over the recent and top visited lists, you can write your own functions to transform the path names and determine which visits to save. You can also enrich each visit with other fields and customize the chip colors/labels in the visit lists. -#### Transform Pathname Function +##### Transform Pathname Function Provide a `transformPathname` function to transform the pathname before it's processed for visit tracking. This can be used for transforming the pathname for the visit (before any other consideration). As an example, you can treat multiple sub-path visits to be counted as a singular path, e.g. `/entity-path/sub1` , `/entity-path/sub-2`, `/entity-path/sub-2/sub-sub-2` can all be mapped to `/entity-path` so visits to any of those routes are all counted as the same. @@ -548,7 +533,7 @@ export const apis: AnyApiFactory[] = [ ]; ``` -#### Can Save Function +##### Can Save Function Provide a `canSave` function to determine which visits should be tracked and saved. This allows you to conditionally save visits to the list: @@ -586,7 +571,7 @@ export const apis: AnyApiFactory[] = [ ]; ``` -#### Enrich Visit Function +##### Enrich Visit Function You can also add the `enrichVisit` function to put additional values on each `Visit`. The values could later be used to customize the chips in the `VisitList`. For example, you could add the entity `type` on the `Visit` so that `type` is used for labels instead of `kind`. @@ -637,7 +622,7 @@ export const apis: AnyApiFactory[] = [ ]; ``` -#### Custom Chip Colors and Labels +##### Custom Chip Colors and Labels To provide your own chip colors and/or labels for the recent and top visited lists, wrap the components in `VisitDisplayProvider` with `getChipColor` and `getChipLabel` functions. The colors provided will be used instead of the hard coded [`colorVariants`](https://github.com/backstage/backstage/blob/2da352043425bcab4c4422e4d2820c26c0a83382/packages/theme/src/base/pageTheme.ts#L46) provided via `@backstage/theme`. @@ -680,16 +665,3 @@ export default function HomePage() { ); } ``` - -## Contributing - -### Homepage Components - -We believe that people have great ideas for what makes a useful Home Page, and we want to make it easy for everyone to benefit from the effort you put in to create something cool for the Home Page. Therefore, a great way of contributing is by simply creating more Home Page Components that can then be used by everyone when composing their own Home Page. If they are tightly coupled to an existing plugin, it is recommended to allow them to live within that plugin, for convenience and to limit complex dependencies. On the other hand, if there's no clear plugin that the component is based on, it's also fine to contribute them into the [home plugin](/plugins/home/src/homePageComponents) - -Additionally, the API is at a very early state, so contributing additional use cases may expose weaknesses in the current solution that we may iterate on to provide more flexibility and ease of use for those who wish to develop components for the Home Page. - -### Homepage Templates - -We are hoping that we together can build up a collection of Homepage templates. We therefore put together a place where we can collect all the templates for the Home Plugin in the [storybook](https://backstage.io/storybook/?path=/story/plugins-home-templates). -If you would like to contribute with a template, start by taking a look at the [DefaultTemplate storybook example](/packages/app-legacy/src/components/home/templates/DefaultTemplate.stories.tsx) or [CustomizableTemplate storybook example](/packages/app-legacy/src/components/home/templates/CustomizableTemplate.stories.tsx) to create your own, and then open a PR with your suggestion. diff --git a/plugins/kubernetes-backend/config.d.ts b/plugins/kubernetes-backend/config.d.ts index 74b2f57a31..7ab482822e 100644 --- a/plugins/kubernetes-backend/config.d.ts +++ b/plugins/kubernetes-backend/config.d.ts @@ -90,6 +90,13 @@ export interface Config { skipTLSVerify?: boolean; /** @visibility frontend */ skipMetricsLookup?: boolean; + /** + * The type of endpoint to use for connecting to the cluster. + * 'public' uses the public IP endpoint (default). + * 'dns' uses the DNS-based control plane endpoint. + * @visibility frontend + */ + endpointType?: 'public' | 'dns'; } >; customResources?: Array<{ diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index 2b63814f62..b976532a47 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -17,6 +17,7 @@ import { ANNOTATION_KUBERNETES_AUTH_PROVIDER } from '@backstage/plugin-kubernetes-common'; import { ConfigReader, Config } from '@backstage/config'; import { GkeClusterLocator } from './GkeClusterLocator'; +import { mockServices } from '@backstage/backend-test-utils'; import * as container from '@google-cloud/container'; const mockedListClusters = jest.fn(); @@ -31,8 +32,11 @@ jest.mock('@google-cloud/container', () => { }); describe('GkeClusterLocator', () => { + const logger = mockServices.logger.mock(); + beforeEach(() => { mockedListClusters.mockRestore(); + jest.clearAllMocks(); }); describe('config-parsing', () => { it('should accept missing region', async () => { @@ -41,9 +45,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); expect(mockedListClusters).toHaveBeenCalledTimes(0); }); @@ -53,13 +61,34 @@ describe('GkeClusterLocator', () => { }); expect(() => - GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any), + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ), ).toThrow("Missing required config value at 'projectId'"); expect(mockedListClusters).toHaveBeenCalledTimes(0); }); + it('should reject invalid endpointType', async () => { + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + endpointType: 'invalid', + }); + + expect(() => + GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ), + ).toThrow("Invalid endpointType 'invalid', must be one of: public, dns"); + }); }); describe('listClusters', () => { it('empty clusters returns empty cluster details', async () => { @@ -75,9 +104,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -106,9 +139,13 @@ describe('GkeClusterLocator', () => { skipMetricsLookup: true, }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -143,9 +180,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -185,9 +226,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -240,9 +285,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -301,9 +350,13 @@ describe('GkeClusterLocator', () => { ], }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -332,9 +385,13 @@ describe('GkeClusterLocator', () => { region: 'some-region', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); await expect(sut.getClusters()).rejects.toThrow( 'There was an error retrieving clusters from GKE for projectId=some-project region=some-region; caused by Error: some error', @@ -365,9 +422,13 @@ describe('GkeClusterLocator', () => { exposeDashboard: true, }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -408,9 +469,13 @@ describe('GkeClusterLocator', () => { projectId: 'some-project', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -442,9 +507,13 @@ describe('GkeClusterLocator', () => { authProvider: 'googleServiceAccount', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -478,9 +547,13 @@ describe('GkeClusterLocator', () => { authProvider: 'differentValue', }); - const sut = GkeClusterLocator.fromConfigWithClient(config, { - listClusters: mockedListClusters, - } as any); + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); const result = await sut.getClusters(); @@ -494,13 +567,99 @@ describe('GkeClusterLocator', () => { }, ]); }); + it('uses DNS endpoint when endpointType is dns', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + controlPlaneEndpointsConfig: { + dnsEndpointConfig: { + endpoint: 'gke-abc123.us-central1.gke.goog', + }, + }, + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + endpointType: 'dns', + }); + + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'some-cluster', + url: 'https://gke-abc123.us-central1.gke.goog', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, + skipTLSVerify: false, + skipMetricsLookup: false, + }, + ]); + }); + it('falls back to public IP with warning when endpointType is dns but no DNS endpoint available', async () => { + mockedListClusters.mockReturnValueOnce([ + { + clusters: [ + { + name: 'some-cluster', + endpoint: '1.2.3.4', + }, + ], + }, + ]); + + const config: Config = new ConfigReader({ + type: 'gke', + projectId: 'some-project', + region: 'some-region', + endpointType: 'dns', + }); + + const sut = GkeClusterLocator.fromConfigWithClient( + config, + { + listClusters: mockedListClusters, + } as any, + logger, + ); + + const result = await sut.getClusters(); + + expect(result).toStrictEqual([ + { + name: 'some-cluster', + url: 'https://1.2.3.4', + authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'google' }, + skipTLSVerify: false, + skipMetricsLookup: false, + }, + ]); + expect(logger.info).toHaveBeenCalledWith( + "Cluster 'some-cluster' has endpointType 'dns' configured but no DNS endpoint available, falling back to public IP", + ); + }); it('constructs ClusterManagerClient with identifying metadata', async () => { const configs: Config = new ConfigReader({ type: 'gke', projectId: 'some-project', }); - GkeClusterLocator.fromConfig(configs); + GkeClusterLocator.fromConfig(configs, logger); expect(container.v1.ClusterManagerClient).toHaveBeenCalledWith({ libName: 'backstage/kubernetes-backend.GkeClusterLocator', diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 2022d5f14a..844b1f8e82 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -24,6 +24,7 @@ import { ClusterDetails, KubernetesClustersSupplier, } from '@backstage/plugin-kubernetes-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; import packageinfo from '../../package.json'; interface MatchResourceLabelEntry { @@ -39,22 +40,28 @@ type GkeClusterLocatorOptions = { skipMetricsLookup?: boolean; exposeDashboard?: boolean; matchingResourceLabels?: MatchResourceLabelEntry[]; + endpointType?: 'public' | 'dns'; }; +const VALID_ENDPOINT_TYPES = ['public', 'dns'] as const; + export class GkeClusterLocator implements KubernetesClustersSupplier { private readonly options: GkeClusterLocatorOptions; private readonly client: container.v1.ClusterManagerClient; + private readonly logger: LoggerService; private clusterDetails: ClusterDetails[] | undefined; private hasClusterDetails: boolean; constructor( options: GkeClusterLocatorOptions, client: container.v1.ClusterManagerClient, + logger: LoggerService, clusterDetails: ClusterDetails[] | undefined = undefined, hasClusterDetails: boolean = false, ) { this.options = options; this.client = client; + this.logger = logger; this.clusterDetails = clusterDetails; this.hasClusterDetails = hasClusterDetails; } @@ -62,6 +69,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, + logger: LoggerService, refreshInterval?: Duration, ): GkeClusterLocator { const matchingResourceLabels: MatchResourceLabelEntry[] = @@ -83,8 +91,9 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { config.getOptionalBoolean('skipMetricsLookup') ?? false, exposeDashboard: config.getOptionalBoolean('exposeDashboard') ?? false, matchingResourceLabels, + endpointType: parseEndpointType(config.getOptionalString('endpointType')), }; - const gkeClusterLocator = new GkeClusterLocator(options, client); + const gkeClusterLocator = new GkeClusterLocator(options, client, logger); if (refreshInterval) { runPeriodically( () => gkeClusterLocator.refreshClusters(), @@ -97,6 +106,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { // Added an `x-goog-api-client` header to API requests made by the GKE cluster locator to clearly identify API requests from this plugin. static fromConfig( config: Config, + logger: LoggerService, refreshInterval: Duration | undefined = undefined, ): GkeClusterLocator { return GkeClusterLocator.fromConfigWithClient( @@ -105,6 +115,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { libName: `backstage/kubernetes-backend.GkeClusterLocator`, libVersion: packageinfo.version, }), + logger, refreshInterval, ); } @@ -117,6 +128,24 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { return this.clusterDetails ?? []; } + private getClusterUrl( + cluster: container.protos.google.container.v1.ICluster, + ): string { + if (this.options.endpointType === 'dns') { + const dnsEndpoint = + cluster.controlPlaneEndpointsConfig?.dnsEndpointConfig?.endpoint; + if (dnsEndpoint) { + return `https://${dnsEndpoint}`; + } + this.logger.info( + `Cluster '${ + cluster.name ?? 'unknown' + }' has endpointType 'dns' configured but no DNS endpoint available, falling back to public IP`, + ); + } + return `https://${cluster.endpoint ?? ''}`; + } + // TODO pass caData into the object async refreshClusters(): Promise { const { @@ -146,7 +175,7 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { .map(r => ({ // TODO filter out clusters which don't have name or endpoint name: r.name ?? 'unknown', - url: `https://${r.endpoint ?? ''}`, + url: this.getClusterUrl(r), authMetadata: { [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: authProvider }, skipTLSVerify, skipMetricsLookup, @@ -170,3 +199,25 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } } } + +function isValidEndpointType( + value: string, +): value is (typeof VALID_ENDPOINT_TYPES)[number] { + return VALID_ENDPOINT_TYPES.includes( + value as (typeof VALID_ENDPOINT_TYPES)[number], + ); +} + +function parseEndpointType(value: string | undefined): 'public' | 'dns' { + if (value === undefined) { + return 'public'; + } + if (isValidEndpointType(value)) { + return value; + } + throw new Error( + `Invalid endpointType '${value}', must be one of: ${VALID_ENDPOINT_TYPES.join( + ', ', + )}`, + ); +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts index c18ff035e7..8437c81f45 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/index.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -101,6 +101,7 @@ export const getCombinedClusterSupplier = ( case 'gke': return GkeClusterLocator.fromConfig( clusterLocatorMethod, + logger, refreshInterval, ); default: diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md index e2c967902b..fe64aea257 100644 --- a/plugins/kubernetes/README.md +++ b/plugins/kubernetes/README.md @@ -8,15 +8,20 @@ It will elevate the visibility of errors where identified, and provide drill dow It directly interfaces with the [Kubernetes Backend Plugin (`@backstage-plugin-kubernetes-backend`)](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). -_This plugin was created through the Backstage CLI_ - ## Introduction See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin. ## Setup & Configuration -This plugin must be explicitly added to a Backstage app, along with it's peer backend plugin. +This plugin must be installed in a Backstage app, along with its peer backend plugin. + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-kubernetes +``` + +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). It requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane. @@ -24,35 +29,9 @@ In addition, configuration of an entity's `catalog-info.yaml` helps identify whi For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview). -## Getting started +### Enabling the entity content tab -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/kubernetes](http://localhost:3000/catalog/default/component/:component-name/kubernetes). - -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. - -### Integrating with `EntityPage` (New Frontend System) - -Follow this section if you are using Backstage's [new frontend system](https://backstage.io/docs/frontend-system/). - -1. Import `kubernetesPlugin` in your `App.tsx` and add it to your app's `features` array: - -```typescript -import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; - -// ... - -export const app = createApp({ - features: [ - // ... - kubernetesPlugin, - // ... - ], -}); -``` - -2. Next, enable your desired extensions in `app-config.yaml`. +Enable the Kubernetes entity content extension in your `app-config.yaml`: ```yaml app: @@ -77,3 +56,11 @@ app: - resource - system ``` + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/kubernetes](http://localhost:3000/catalog/default/component/:component-name/kubernetes). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/mcp-actions-backend/README.md b/plugins/mcp-actions-backend/README.md index 7793a9f5f9..d496cf0f03 100644 --- a/plugins/mcp-actions-backend/README.md +++ b/plugins/mcp-actions-backend/README.md @@ -73,7 +73,7 @@ export const myPlugin = createBackendPlugin({ ### Namespaced Tool Names -By default, MCP tool names include the plugin ID prefix to avoid collisions across plugins. For example, an action registered as `greet-user` by `my-custom-plugin` is exposed as `my-custom-plugin:greet-user`. +By default, MCP tool names include the plugin ID prefix to avoid collisions across plugins. For example, an action registered as `greet-user` by `my-custom-plugin` is exposed as `my-custom-plugin.greet-user`. You can disable this if you need the short names for backward compatibility: diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index a535f4c511..a530f5b038 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -16,10 +16,22 @@ export interface Config { mcpActions?: { + /** + * Display name for the MCP server. Defaults to "backstage". + * Used when running a single bundled server without mcpActions.servers. + */ + name?: string; + + /** + * Description of the MCP server. + * Used when running a single bundled server without mcpActions.servers. + */ + description?: string; + /** * When true, MCP tool names include the plugin ID prefix to avoid * collisions across plugins. For example an action registered as - * "get-entity" by the catalog plugin becomes "catalog:get-entity". + * "get-entity" by the catalog plugin becomes "catalog.get-entity". * Defaults to true. */ namespacedToolNames?: boolean; diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index ceae509f9f..2c03800915 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -118,7 +118,7 @@ describe('Mcp Backend', () => { required: ['name'], type: 'object', }, - name: 'local:make-greeting', + name: 'local.make-greeting', }, ]); }); @@ -161,7 +161,7 @@ describe('Mcp Backend', () => { required: ['name'], type: 'object', }, - name: 'local:make-greeting', + name: 'local.make-greeting', }, ]); }); @@ -264,7 +264,7 @@ describe('Mcp Backend', () => { ListToolsResultSchema, ); expect(catalogResult.tools).toHaveLength(1); - expect(catalogResult.tools[0].name).toBe('catalog-actions:get-entity'); + expect(catalogResult.tools[0].name).toBe('catalog-actions.get-entity'); const scaffolderClient = new Client({ name: 'test', version: '1.0' }); const scaffolderTransport = new StreamableHTTPClientTransport( @@ -277,7 +277,7 @@ describe('Mcp Backend', () => { ); expect(scaffolderResult.tools).toHaveLength(1); expect(scaffolderResult.tools[0].name).toBe( - 'scaffolder-actions:create-app', + 'scaffolder-actions.create-app', ); }); }); diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 08f9db350f..f8844249f6 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -87,9 +87,17 @@ export const mcpPlugin = createBackendPlugin({ router.use(`/v1/${key}`, streamableRouter); } } else { + const serverConfig = { + name: config.getOptionalString('mcpActions.name') ?? 'backstage', + description: config.getOptionalString('mcpActions.description'), + includeRules: [], + excludeRules: [], + }; + const sseRouter = createSseRouter({ mcpService, httpAuth, + serverConfig, }); const streamableRouter = createStreamableRouter({ @@ -97,6 +105,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, logger, metrics, + serverConfig, }); router.use('/v1/sse', sseRouter); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 8a6656b91c..1a7dc507c3 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -96,7 +96,7 @@ describe('McpService', () => { required: ['input'], type: 'object', }, - name: 'test:mock-action', + name: 'test.mock-action', }, ]); @@ -196,7 +196,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:mock-action', arguments: { input: 'test' } }, + params: { name: 'test.mock-action', arguments: { input: 'test' } }, }, CallToolResultSchema, ); @@ -226,7 +226,7 @@ describe('McpService', () => { expect.any(Number), expect.objectContaining({ 'mcp.method.name': 'tools/call', - 'gen_ai.tool.name': 'test:mock-action', + 'gen_ai.tool.name': 'test.mock-action', 'gen_ai.operation.name': 'execute_tool', }), ); @@ -329,7 +329,7 @@ describe('McpService', () => { client.request( { method: 'tools/call', - params: { name: 'test:failing-action', arguments: {} }, + params: { name: 'test.failing-action', arguments: {} }, }, CallToolResultSchema, ), @@ -341,7 +341,7 @@ describe('McpService', () => { expect.any(Number), expect.objectContaining({ 'mcp.method.name': 'tools/call', - 'gen_ai.tool.name': 'test:failing-action', + 'gen_ai.tool.name': 'test.failing-action', 'gen_ai.operation.name': 'execute_tool', 'error.type': 'CustomError', }), @@ -388,7 +388,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:failing-action', arguments: { value: 'test' } }, + params: { name: 'test.failing-action', arguments: { value: 'test' } }, }, CallToolResultSchema, ); @@ -444,7 +444,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:not-found-action', arguments: { id: 'abc' } }, + params: { name: 'test.not-found-action', arguments: { id: 'abc' } }, }, CallToolResultSchema, ); @@ -464,6 +464,7 @@ describe('McpService', () => { const fakeActions = [ { id: 'catalog:get-entity', + pluginId: 'catalog', name: 'get-entity', title: 'Get Entity', description: 'Fetch an entity', @@ -475,6 +476,7 @@ describe('McpService', () => { }, { id: 'catalog:delete-entity', + pluginId: 'catalog', name: 'delete-entity', title: 'Delete Entity', description: 'Delete an entity', @@ -486,6 +488,7 @@ describe('McpService', () => { }, { id: 'scaffolder:create-app', + pluginId: 'scaffolder', name: 'create-app', title: 'Create App', description: 'Create an app', @@ -571,8 +574,8 @@ describe('McpService', () => { expect(result.tools).toHaveLength(2); expect(result.tools.map(t => t.name)).toEqual([ - 'catalog:get-entity', - 'catalog:delete-entity', + 'catalog.get-entity', + 'catalog.delete-entity', ]); }); @@ -611,7 +614,7 @@ describe('McpService', () => { ); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('catalog:get-entity'); + expect(result.tools[0].name).toBe('catalog.get-entity'); }); it('should apply include filter rules with glob patterns', async () => { @@ -649,7 +652,7 @@ describe('McpService', () => { ); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('catalog:get-entity'); + expect(result.tools[0].name).toBe('catalog.get-entity'); }); it('should reject tool calls for actions outside the filtered set', async () => { @@ -684,7 +687,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'catalog:get-entity', arguments: {} }, + params: { name: 'catalog.get-entity', arguments: {} }, }, CallToolResultSchema, ); @@ -694,7 +697,7 @@ describe('McpService', () => { { type: 'text', text: expect.stringContaining( - 'Action "catalog:get-entity" not found', + 'Action "catalog.get-entity" not found', ), }, ], @@ -703,6 +706,88 @@ describe('McpService', () => { }); }); + describe('server name and description', () => { + it('should default server name to backstage when no config is provided', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('backstage'); + expect(serverInfo?.description).toBeUndefined(); + }); + + it('should use name and description from server config', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + serverConfig: { + name: 'My Custom Server', + description: 'A custom MCP server for testing', + includeRules: [], + excludeRules: [], + }, + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('My Custom Server'); + expect(serverInfo?.description).toBe('A custom MCP server for testing'); + }); + + it('should omit description when not provided in config', async () => { + const mcpService = await McpService.create({ + actions: actionsRegistryServiceMock(), + metrics: metricsServiceMock.mock(), + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + serverConfig: { + name: 'Named Server', + includeRules: [], + excludeRules: [], + }, + }); + + const client = new Client({ name: 'test', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('Named Server'); + expect(serverInfo?.description).toBeUndefined(); + }); + }); + describe('namespaced tool names', () => { it('should use action ID as tool name by default', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); @@ -739,7 +824,7 @@ describe('McpService', () => { ListToolsResultSchema, ); - expect(result.tools[0].name).toBe('test:mock-action'); + expect(result.tools[0].name).toBe('test.mock-action'); }); it('should use short action name when namespacing is disabled', async () => { @@ -814,7 +899,7 @@ describe('McpService', () => { const result = await client.request( { method: 'tools/call', - params: { name: 'test:mock-action', arguments: {} }, + params: { name: 'test.mock-action', arguments: {} }, }, CallToolResultSchema, ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 18a5e57908..49eea11d72 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -76,13 +76,14 @@ export class McpService { credentials: BackstageCredentials; serverConfig?: McpServerConfig; }) { - const serverName = serverConfig?.name ?? 'backstage'; - const server = new McpServer( { - name: serverName, + name: serverConfig?.name ?? 'backstage', // TODO: this version will most likely change in the future. version, + ...(serverConfig?.description && { + description: serverConfig.description, + }), }, { capabilities: { tools: {} } }, ); @@ -222,7 +223,7 @@ export class McpService { private getToolName(action: ActionsServiceAction): string { if (this.namespacedToolNames) { - return action.id; + return `${action.pluginId}.${action.name}`; } return action.name; } diff --git a/plugins/mui-to-bui/README.md b/plugins/mui-to-bui/README.md index d964b4827f..0b760c4ef2 100644 --- a/plugins/mui-to-bui/README.md +++ b/plugins/mui-to-bui/README.md @@ -6,17 +6,24 @@ The Backstage UI Themer helps you convert an existing MUI v5 theme into Backstag ## Installation -### 1) Add the dependency to your app - -Run this from your Backstage repo root: +Add the dependency to your app: ```bash yarn --cwd packages/app add @backstage/plugin-mui-to-bui ``` -### 2) Wire it up depending on your frontend system +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). -#### Old frontend system (legacy `App.tsx` with ``) +## Accessing the Themer page + +- Navigate to `/mui-to-bui` in your Backstage app (for example `http://localhost:3000/mui-to-bui`). +- Optional: Add a sidebar/link in your app that points to `/mui-to-bui` if you want a permanent navigation entry. + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the +plugin into your app as outlined in this section. If you are on the new frontend +system, you can skip this. Add a route for the page in your app: @@ -34,30 +41,3 @@ export const App = () => ( ); ``` - -#### New frontend system - -If package discovery is enabled in your app, this plugin is picked up automatically after installation — no code changes required. Just navigate to `/mui-to-bui`. - -If you prefer explicit registration (or don't use discovery), register the plugin as a feature. The page route (`/mui-to-bui`) is provided by the plugin. - -```tsx -// packages/app/src/App.tsx (or your app entry where you call createApp) -import React from 'react'; -import { createApp } from '@backstage/frontend-defaults'; -import buiThemerPlugin from '@backstage/plugin-mui-to-bui'; - -const app = createApp({ - features: [ - // ...other features - buiThemerPlugin, - ], -}); - -export default app.createRoot(); -``` - -## Accessing the Themer page - -- Navigate to `/mui-to-bui` in your Backstage app (for example `http://localhost:3000/mui-to-bui`). -- Optional: Add a sidebar/link in your app that points to `/mui-to-bui` if you want a permanent navigation entry. diff --git a/plugins/org/README-alpha.md b/plugins/org/README-alpha.md index 28e274b58c..db8a84f35c 100644 --- a/plugins/org/README-alpha.md +++ b/plugins/org/README-alpha.md @@ -1,8 +1,6 @@ -# Org Plugin +# Org Plugin - Extension Reference -> [!WARNING] -> This documentation is made for those using the experimental new Frontend system. -> If you are not using the new frontend system, please go [here](./README.md). +This page contains detailed documentation for all extensions provided by the `@backstage/plugin-org` plugin. For general information about the plugin, see the [README](./README.md). This is a plugin that extends the Catalog entity page with some users and groups overview cards: diff --git a/plugins/org/README.md b/plugins/org/README.md index 9cf165f06d..062f88fa98 100644 --- a/plugins/org/README.md +++ b/plugins/org/README.md @@ -1,8 +1,5 @@ # Org Plugin for Backstage -> Disclaimer: -> If you are looking for documentation on the experimental new frontend system support, please go [here](./README-alpha.md). - ## Features - Show Group Page @@ -21,6 +18,33 @@ Here's an example of what the User Profile looks like: ![Group Page example](./docs/user-profile-example.png) +## Installation + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-org +``` + +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). + +You can enable entity cards on the catalog entity page through configuration: + +```yaml +# app-config.yaml +app: + extensions: + - entity-card:org/group-profile + - entity-card:org/members-list + - entity-card:org/ownership + - entity-card:org/user-profile +``` + +For the full list of available extensions and their configuration options, see the [README-alpha.md](./README-alpha.md). + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + ### MyGroupsSidebarItem The MyGroupsSidebarItem provides quick access to the group(s) the logged in user is a member of directly in the sidebar. diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index b6b2d97ac8..9e930a7866 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -24,10 +24,13 @@ export function createGithubActionsDispatchAction(options: { workflowId: string; branchOrTagName: string; workflowInputs?: Record | undefined; + returnWorkflowRunDetails?: boolean | undefined; token?: string | undefined; }, { - [x: string]: any; + workflowRunId?: number | undefined; + workflowRunUrl?: string | undefined; + workflowRunHtmlUrl?: string | undefined; }, 'v2' >; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts index 5583b745d5..077af89c2a 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.test.ts @@ -124,4 +124,43 @@ describe('github:actions:dispatch', () => { inputs: workflowInputs, }); }); + + it('should call createWorkflowDispatch with return_run_details when using the returnWorkflowRunDetails example', async () => { + mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({ + data: { + workflow_run_id: 123, + run_url: 'https://api.github.com/repos/owner/repo/actions/runs/123', + html_url: 'https://github.com/owner/repo/actions/runs/123', + }, + }); + + const exampleInput = yaml.parse(examples[3].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { + input: { + ...exampleInput, + repoUrl: 'github.com?repo=repo&owner=owner', + }, + }); + await action.handler(ctx); + + expect( + mockOctokit.rest.actions.createWorkflowDispatch, + ).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'owner', + repo: 'repo', + return_run_details: true, + }), + ); + + expect(ctx.output).toHaveBeenCalledWith('workflowRunId', 123); + expect(ctx.output).toHaveBeenCalledWith( + 'workflowRunUrl', + 'https://api.github.com/repos/owner/repo/actions/runs/123', + ); + expect(ctx.output).toHaveBeenCalledWith( + 'workflowRunHtmlUrl', + 'https://github.com/owner/repo/actions/runs/123', + ); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.ts index 33ad86dd89..2b8baafdbe 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.examples.ts @@ -71,4 +71,21 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'GitHub Action Workflow returning run details', + example: yaml.stringify({ + steps: [ + { + action: 'github:actions:dispatch', + name: 'Dispatch GitHub Action Workflow and get run ID', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'WORKFLOW_ID', + branchOrTagName: 'main', + returnWorkflowRunDetails: true, + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts index 94a51e5b4e..fc8cfcce0b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.test.ts @@ -130,4 +130,64 @@ describe('github:actions:dispatch', () => { inputs: workflowInputs, }); }); + + it('should call createWorkflowDispatch with return_run_details when returnWorkflowRunDetails is true', async () => { + mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({ + data: { + workflow_run_id: 123, + run_url: 'https://api.github.com/repos/owner/repo/actions/runs/123', + html_url: 'https://github.com/owner/repo/actions/runs/123', + }, + }); + + const ctx = Object.assign({}, mockContext, { + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'dispatch_workflow', + branchOrTagName: 'main', + returnWorkflowRunDetails: true, + }, + }); + await action.handler(ctx); + + expect( + mockOctokit.rest.actions.createWorkflowDispatch, + ).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'owner', + repo: 'repo', + workflow_id: 'dispatch_workflow', + ref: 'main', + return_run_details: true, + }), + ); + + expect(ctx.output).toHaveBeenCalledWith('workflowRunId', 123); + expect(ctx.output).toHaveBeenCalledWith( + 'workflowRunUrl', + 'https://api.github.com/repos/owner/repo/actions/runs/123', + ); + expect(ctx.output).toHaveBeenCalledWith( + 'workflowRunHtmlUrl', + 'https://github.com/owner/repo/actions/runs/123', + ); + }); + + it('should not set outputs when returnWorkflowRunDetails is false', async () => { + mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({ + data: undefined, + }); + + const ctx = Object.assign({}, mockContext, { + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'dispatch_workflow', + branchOrTagName: 'main', + returnWorkflowRunDetails: false, + }, + }); + await action.handler(ctx); + + expect(ctx.output).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts index f35cbcc04c..00dd72a9e3 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubActionsDispatch.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { InputError } from '@backstage/errors'; +import { assertError, InputError } from '@backstage/errors'; import { GithubCredentialsProvider, ScmIntegrations, @@ -62,7 +62,14 @@ export function createGithubActionsDispatchAction(options: { z .record(z.string(), { description: - 'Inputs keys and values to send to GitHub Action configured on the workflow file. The maximum number of properties is 10.', + 'Inputs keys and values to send to GitHub Action configured on the workflow file. The maximum number of properties is 25.', + }) + .optional(), + returnWorkflowRunDetails: z => + z + .boolean({ + description: + 'If true, returns the workflow run ID and URLs as action outputs.', }) .optional(), token: z => @@ -73,6 +80,14 @@ export function createGithubActionsDispatchAction(options: { }) .optional(), }, + output: { + workflowRunId: z => + z.number({ description: 'The triggered workflow run ID' }).optional(), + workflowRunUrl: z => + z.string({ description: 'API URL of the workflow run' }).optional(), + workflowRunHtmlUrl: z => + z.string({ description: 'HTML URL of the workflow run' }).optional(), + }, }, async handler(ctx) { const { @@ -80,6 +95,7 @@ export function createGithubActionsDispatchAction(options: { workflowId, branchOrTagName, workflowInputs, + returnWorkflowRunDetails, token: providedToken, } = ctx.input; @@ -106,20 +122,65 @@ export function createGithubActionsDispatchAction(options: { log: ctx.logger, }); - await ctx.checkpoint({ - key: `create.workflow.dispatch.${owner}.${repo}.${workflowId}`, - fn: async () => { - await client.rest.actions.createWorkflowDispatch({ - owner, - repo, - workflow_id: workflowId, - ref: branchOrTagName, - inputs: workflowInputs, - }); + try { + const runDetails = await ctx.checkpoint({ + key: `create.workflow.dispatch.${owner}.${repo}.${workflowId}`, + fn: async () => { + const dispatchParams = { + owner, + repo, + workflow_id: workflowId, + ref: branchOrTagName, + inputs: workflowInputs, + ...(returnWorkflowRunDetails ? { return_run_details: true } : {}), + }; + const response = await client.rest.actions.createWorkflowDispatch( + dispatchParams, + ); - ctx.logger.info(`Workflow ${workflowId} dispatched successfully`); - }, - }); + ctx.logger.info(`Workflow ${workflowId} dispatched successfully`); + + if (returnWorkflowRunDetails && response.data) { + // GitHub's API returns 200 with run details when return_run_details is true. + // @octokit/openapi-types still types this as OctokitResponse because + // it hasn't picked up the updated GitHub OpenAPI spec yet. + // See: https://github.blog/changelog/2026-02-19-workflow-dispatch-api-now-returns-run-ids/ + // This cast can be removed once @octokit/openapi-types includes the updated spec. + // Note: only supported on GitHub.com and GitHub Enterprise Cloud, not GitHub Enterprise Server + const data = response.data as unknown as { + workflow_run_id: number; + run_url: string; + html_url: string; + }; + + return { + workflowRunId: data.workflow_run_id, + workflowRunUrl: data.run_url, + workflowRunHtmlUrl: data.html_url, + }; + } + return null; + }, + }); + + if (runDetails) { + ctx.output('workflowRunId', runDetails.workflowRunId); + ctx.output('workflowRunUrl', runDetails.workflowRunUrl); + ctx.output('workflowRunHtmlUrl', runDetails.workflowRunHtmlUrl); + + if (runDetails.workflowRunHtmlUrl) { + ctx.logger.info( + `Workflow run url: ${runDetails.workflowRunHtmlUrl}`, + ); + } + } + } catch (e) { + assertError(e); + ctx.logger.warn( + `Failed: dispatching workflow '${workflowId}' on repo: '${repo}', ${e.message}`, + ); + throw e; + } }, }); } diff --git a/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts new file mode 100644 index 0000000000..7ddf498d9d --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; +import { LogEvent } from '@backstage/plugin-scaffolder-common'; +import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction'; + +describe('createGetScaffolderTaskLogsAction', () => { + it('should return log events for a task', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + const mockEvents: LogEvent[] = [ + { + id: 1, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:00Z', + type: 'log', + body: { message: 'Starting step', stepId: 'step-1' }, + }, + { + id: 2, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:01Z', + type: 'log', + body: { message: 'Step complete', stepId: 'step-1' }, + }, + { + id: 3, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:02Z', + type: 'completion', + body: { message: 'Task completed', status: 'completed' }, + }, + ]; + + mockScaffolderService.getLogs.mockResolvedValue(mockEvents); + + createGetScaffolderTaskLogsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-scaffolder-task-logs', + input: { taskId: 'task-1' }, + }); + + expect(result.output).toEqual({ + events: [ + { + id: 1, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:00Z', + type: 'log', + body: { + message: 'Starting step', + stepId: 'step-1', + status: undefined, + }, + }, + { + id: 2, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:01Z', + type: 'log', + body: { + message: 'Step complete', + stepId: 'step-1', + status: undefined, + }, + }, + { + id: 3, + taskId: 'task-1', + createdAt: '2025-01-01T00:00:02Z', + type: 'completion', + body: { + message: 'Task completed', + stepId: undefined, + status: 'completed', + }, + }, + ], + }); + expect(mockScaffolderService.getLogs).toHaveBeenCalledWith( + { taskId: 'task-1', after: undefined }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should pass the after parameter through to the service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockScaffolderService.getLogs.mockResolvedValue([]); + + createGetScaffolderTaskLogsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-scaffolder-task-logs', + input: { taskId: 'task-2', after: 42 }, + }); + + expect(result.output).toEqual({ events: [] }); + expect(mockScaffolderService.getLogs).toHaveBeenCalledWith( + { taskId: 'task-2', after: 42 }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should throw when the service call fails', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockScaffolderService.getLogs.mockRejectedValue( + new Error('Internal Server Error'), + ); + + createGetScaffolderTaskLogsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-scaffolder-task-logs', + input: { taskId: 'task-3' }, + }), + ).rejects.toThrow('Internal Server Error'); + }); +}); diff --git a/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts new file mode 100644 index 0000000000..08eb3a504f --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createGetScaffolderTaskLogsAction.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; + +export const createGetScaffolderTaskLogsAction = ({ + actionsRegistry, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'get-scaffolder-task-logs', + title: 'Get Scaffolder Task Logs', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: ` +Retrieve the log events for a scaffolder task. +Each log event has a type (log, completion, cancelled, or recovered), a body containing a message and optional step ID and status. +Use the after parameter to fetch only events after a specific event ID for incremental polling. + `, + schema: { + input: z => + z.object({ + taskId: z.string().describe('The ID of the scaffolder task'), + after: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Return only log events after this event ID for incremental polling', + ), + }), + output: z => + z + .object({ + events: z + .array( + z.object({ + id: z.number().describe('The event ID'), + taskId: z + .string() + .describe('The ID of the task this event belongs to'), + createdAt: z + .string() + .describe('Timestamp when the event was created'), + type: z + .string() + .describe( + 'Event type: log, completion, cancelled, or recovered', + ), + body: z + .object({ + message: z.string().describe('The log message'), + stepId: z + .string() + .optional() + .describe('The step ID associated with this event'), + status: z + .string() + .optional() + .describe('The task status at the time of this event'), + }) + .describe('The event body'), + }), + ) + .describe('The list of log events for the task'), + }) + .describe('Object containing the events array'), + }, + action: async ({ input, credentials }) => { + const events = await scaffolderService.getLogs( + { taskId: input.taskId, after: input.after }, + { credentials }, + ); + + return { + output: { + events: events.map(event => ({ + id: event.id, + taskId: event.taskId, + createdAt: event.createdAt, + type: event.type, + body: { + message: event.body.message, + stepId: event.body.stepId, + status: event.body.status, + }, + })), + }, + }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 4beb39d832..2dee76d1da 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -19,6 +19,7 @@ import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; +import { createGetScaffolderTaskLogsAction } from './createGetScaffolderTaskLogsAction'; export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; @@ -32,4 +33,5 @@ export const createScaffolderActions = (options: { }); createDryRunTemplateAction(options); createListScaffolderActionsAction(options); + createGetScaffolderTaskLogsAction(options); }; diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index 54282f370d..24b51ed989 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -318,6 +318,21 @@ export const parseRepoUrl: ( project?: string; }; +// @public (undocumented) +export function removeFiles(options: { + dir: string; + filepath: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: LoggerService | undefined; +}): Promise; + // @public export interface ScaffolderActionsExtensionPoint { // (undocumented) diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts index d70ddf3bc0..96a8b96242 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts @@ -17,6 +17,7 @@ import { Git } from '../scm'; import { addFiles, + removeFiles, cloneRepo, commitAndPushBranch, commitAndPushRepo, @@ -31,6 +32,7 @@ jest.mock('../scm', () => ({ fromAuth: jest.fn().mockReturnValue({ init: jest.fn(), add: jest.fn(), + remove: jest.fn(), checkout: jest.fn(), branch: jest.fn(), commit: jest @@ -528,6 +530,47 @@ describe('addFiles', () => { }); }); +describe('removeFiles', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await removeFiles({ + dir: '/tmp/repo/dir/', + filepath: 'file-to-remove.txt', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('removes the file', () => { + expect(mockedGit.remove).toHaveBeenCalledWith({ + filepath: 'file-to-remove.txt', + dir: '/tmp/repo/dir/', + }); + }); + }); + + it('with token', async () => { + await removeFiles({ + dir: '/tmp/repo/dir/', + filepath: 'file-to-remove.txt', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.remove).toHaveBeenCalledWith({ + filepath: 'file-to-remove.txt', + dir: '/tmp/repo/dir/', + }); + }); +}); + describe('commitAndPushBranch', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.ts b/plugins/scaffolder-node/src/actions/gitHelpers.ts index 49650de92e..8ae4ec6a4f 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.ts @@ -204,6 +204,27 @@ export async function addFiles(options: { await git.add({ dir, filepath }); } +/** + * @public + */ +export async function removeFiles(options: { + dir: string; + filepath: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger?: LoggerService | undefined; +}): Promise { + const { dir, filepath, auth, logger } = options; + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.remove({ dir, filepath }); +} + /** * @public */ diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 686d89d844..47939f34cc 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -30,6 +30,7 @@ export { commitAndPushRepo, commitAndPushBranch, addFiles, + removeFiles, createBranch, cloneRepo, } from './gitHelpers'; diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md index 0a79d8afc0..9c9ca15dfa 100644 --- a/plugins/scaffolder/README.md +++ b/plugins/scaffolder/README.md @@ -15,13 +15,72 @@ To check if you already have the package, look under `@backstage/plugin-scaffolder`. The instructions below walk through restoring the plugin, if you previously removed it. -### Install the package - ```bash # From your Backstage root directory yarn --cwd packages/app add @backstage/plugin-scaffolder ``` +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). + +### Troubleshooting + +If you encounter [issues with early closure of the `EventStream`](https://github.com/backstage/backstage/issues/5535) +used to auto-update logs during task execution, you can work around them by enabling +long polling. To do so, update your `packages/app/src/apis.ts` file to register a +`ScaffolderClient` with `useLongPollingLogs` set to `true`. By default, it is `false`. + +```typescript +import { + AnyApiFactory, + createApiFactory, + discoveryApiRef, + fetchApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + scaffolderApiRef, + ScaffolderClient, +} from '@backstage/plugin-scaffolder'; + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ scmIntegrationsApi, discoveryApi, identityApi, fetchApi }) => + new ScaffolderClient({ + discoveryApi, + identityApi, + scmIntegrationsApi, + fetchApi, + useLongPollingLogs: true, + }), + }), + // ... other factories +]; +``` + +This replaces the default implementation of the `scaffolderApiRef`. + +### Local development + +When you develop a new template, action or new ``, then we recommend +to launch the plugin locally using the `createDevApp` of the `./dev/index.tsx` file for testing/Debugging purposes + +To play with it, open a terminal and run the command: `yarn start` within the `./plugins/scaffolder` folder + +**NOTE:** Don't forget to open a second terminal, start your Backstage backend there, +and configure the template locations that you want to test. + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + ### Add the plugin to your `packages/app` Add the root page that the scaffolder plugin provides to your app. You can @@ -78,57 +137,6 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( ``` -### Troubleshooting - -If you encounter the [issue of closing EventStream](https://github.com/backstage/backstage/issues/5535) -which auto-updates logs during task execution, you can enable long polling. To do so, -update your `packages/app/src/apis.ts` file to register a `ScaffolderClient` with the -`useLongPollingLogs` set to `true`. By default, it is `false`. - -```typescript -import { - createApiFactory, - discoveryApiRef, - fetchApiRef, - identityApiRef, -} from '@backstage/core-plugin-api'; -import { - scaffolderApiRef, - ScaffolderClient, -} from '@backstage/plugin-scaffolder'; - -export const apis: AnyApiFactory[] = [ - createApiFactory({ - api: scaffolderApiRef, - deps: { - discoveryApi: discoveryApiRef, - identityApi: identityApiRef, - scmIntegrationsApi: scmIntegrationsApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ scmIntegrationsApi, discoveryApi, identityApi, fetchApi }) => - new ScaffolderClient({ - discoveryApi, - identityApi, - scmIntegrationsApi, - fetchApi, - useLongPollingLogs: true, - }), - }), - // ... other factories -``` - -This replaces the default implementation of the `scaffolderApiRef`. - -### Local development - -When you develop a new template, action or new ``, then we recommend -to launch the plugin locally using the `createDevApp` of the `./dev/index.tsx` file for testing/Debugging purposes - -To play with it, open a terminal and run the command: `yarn start` within the `./plugins/scaffolder` folder - -**NOTE:** Don't forget to open a second terminal and to launch the backend or [backend-next](../../docs/backend-system/index.md) there, using `yarn start` and to specify the locations of the templates to play with ! - ## Links - [scaffolder-backend](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 374249a804..0c105f3a7c 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -61,7 +61,7 @@ const scaffolderEntityIconLink = EntityIconLinkBlueprint.make({ export default createFrontendPlugin({ pluginId: 'scaffolder', title: 'Create', - icon: , + icon: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index a2ecb61e0e..9625f5e946 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -277,7 +277,7 @@ export const searchNavItem = NavItemBlueprint.make({ export default createFrontendPlugin({ pluginId: 'search', title: 'Search', - icon: , + icon: , info: { packageJson: () => import('../package.json') }, extensions: [searchApi, searchPage, searchNavItem], routes: { diff --git a/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx index 0f618013f1..b4084c6411 100644 --- a/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx +++ b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx @@ -56,4 +56,43 @@ describe('LightBox', () => { expect.any(Function), ); }); + + it('does not add onclick event to linked images', async () => { + await TechDocsAddonTester.buildAddonsInTechDocs([]) + .withDom( + , + ) + .withApis([[entityPresentationApiRef, entityPresentationApiMock]]) + .renderWithEffects(); + + expect(screen.getByShadowTestId('linked-fixture').onclick).toBeNull(); + expect( + screen.getByShadowTestId('linked-indirect-fixture').onclick, + ).toBeNull(); + expect(screen.getByShadowTestId('plain-fixture').onclick).toEqual( + expect.any(Function), + ); + }); }); diff --git a/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx index 47409f47c7..89fe842a15 100644 --- a/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx +++ b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx @@ -27,6 +27,7 @@ export const LightBoxAddon = () => { useEffect(() => { let dataSourceImages: DataSource | null = null; + const lightboxImages = images.filter(image => !image.closest('a')); let lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({ pswpModule: PhotoSwipe, @@ -57,10 +58,10 @@ export const LightBoxAddon = () => { `, }); - images.forEach((image, index) => { + lightboxImages.forEach((image, index) => { image.onclick = () => { if (dataSourceImages === null) { - dataSourceImages = images.map(dataSourceImage => { + dataSourceImages = lightboxImages.map(dataSourceImage => { return { element: dataSourceImage, src: dataSourceImage.src, diff --git a/plugins/techdocs-module-addons-contrib/src/plugin.ts b/plugins/techdocs-module-addons-contrib/src/plugin.ts index ac19d682de..6ed09a7db9 100644 --- a/plugins/techdocs-module-addons-contrib/src/plugin.ts +++ b/plugins/techdocs-module-addons-contrib/src/plugin.ts @@ -211,6 +211,8 @@ export const TextSize = techdocsModuleAddonsContribPlugin.provide( * @remarks * The image size of the lightbox image is the same as the image size on the document page. * + * Images that are wrapped in links are ignored to avoid blocking navigation. + * * @example * Here's a simple example: * ``` diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index 6acc6b5e6d..1ec92edfd3 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -280,7 +280,7 @@ const techDocsNavItem = NavItemBlueprint.make({ export default createFrontendPlugin({ pluginId: 'techdocs', title: 'Docs', - icon: , + icon: , info: { packageJson: () => import('../../package.json') }, extensions: [ techDocsClientApi, diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md index a27e5ca524..8644001fe1 100644 --- a/plugins/user-settings/README.md +++ b/plugins/user-settings/README.md @@ -11,7 +11,20 @@ be used in the frontend as a persistent alternative to the builtin `WebStorage`. Please see [the backend README](https://github.com/backstage/backstage/tree/master/plugins/user-settings-backend) for installation instructions. -## Components Usage +## Installation + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-user-settings +``` + +Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins). + +## Old Frontend System + +If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this. + +### Components Usage Add the item to the Sidebar: diff --git a/plugins/user-settings/report.api.md b/plugins/user-settings/report.api.md index 77670bb357..2994f2c13c 100644 --- a/plugins/user-settings/report.api.md +++ b/plugins/user-settings/report.api.md @@ -11,6 +11,7 @@ import { ElementType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; +import { IconElement } from '@backstage/frontend-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react/jsx-runtime'; @@ -35,7 +36,7 @@ export const DefaultProviderSettings: (props: { export const ProviderSettingsItem: (props: { title: string; description: string; - icon: IconComponent; + icon: IconComponent | IconElement; apiRef: ApiRef; }) => JSX_2.Element; diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index c0342a9b46..87b858fcec 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -63,7 +63,7 @@ export const settingsNavItem = NavItemBlueprint.make({ export default createFrontendPlugin({ pluginId: 'user-settings', title: 'Settings', - icon: , + icon: , info: { packageJson: () => import('../package.json') }, extensions: [userSettingsPage, settingsNavItem], routes: { diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 32eaeb0cb9..534e1dc5ad 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { createElement, isValidElement, useEffect, useState } from 'react'; import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import ListItem from '@material-ui/core/ListItem'; @@ -33,6 +33,7 @@ import { errorApiRef, IconComponent, } from '@backstage/core-plugin-api'; +import { IconElement } from '@backstage/frontend-plugin-api'; import { ProviderSettingsAvatar } from './ProviderSettingsAvatar'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { userSettingsTranslationRef } from '../../translation'; @@ -43,10 +44,10 @@ const emptyProfile: ProfileInfo = {}; export const ProviderSettingsItem = (props: { title: string; description: string; - icon: IconComponent; + icon: IconComponent | IconElement; apiRef: ApiRef; }) => { - const { title, description, icon: Icon, apiRef } = props; + const { title, description, icon, apiRef } = props; const api = useApi(apiRef); const errorApi = useApi(errorApiRef); @@ -86,11 +87,14 @@ export const ProviderSettingsItem = (props: { }; }, [api]); + const iconElement = + icon === null || isValidElement(icon) + ? icon + : createElement(icon as IconComponent); + return ( - - - + {iconElement} = 4.11" - checksum: 10/e896a66fecc10639e65873186fdfb71f19d6af650220eb7ea5450725215c3eed8dc6ddcfa1e68a9db8c9facc3326fbc281512ad3ccd8f107f42a2466ce12c18c + checksum: 10/dd97bfc48c01a6d4c5433203232b5e7a1e55e21322bde49033e5f8c4339584fe671a94096144a0810f4ea21dcec8aaaf15823109627e609f8ed1bc5912a345cf languageName: node linkType: hard @@ -31872,9 +31848,9 @@ __metadata: linkType: hard "flatted@npm:^3.1.0, flatted@npm:^3.2.7, flatted@npm:^3.3.4": - version: 3.4.0 - resolution: "flatted@npm:3.4.0" - checksum: 10/6007896f62acb93c811aef52d962c5cd2fa3c703eacfc4146865c3fc8964d3e466c66e10b1c175a84ce0a207fdc761e2e70d7ca982c82604257d45866169af6a + version: 3.4.1 + resolution: "flatted@npm:3.4.1" + checksum: 10/39a308e2ef82d2d8c80ebc74b67d4ff3f668be168137b649440b6735eb9c115d1e0c13ab0d9958b3d2ea9c85087ab7e14c14aa6f625a22b2916d89bbd91bc4a0 languageName: node linkType: hard @@ -32474,10 +32450,10 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0": - version: 1.2.0 - resolution: "get-east-asian-width@npm:1.2.0" - checksum: 10/c9b280e7c7c67fb89fa17e867c4a9d1c9f1321aba2a9ee27bff37fb6ca9552bccda328c70a80c1f83a0e39ba1b7e3427e60f47823402d19e7a41b83417ec047a +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": + version: 1.5.0 + resolution: "get-east-asian-width@npm:1.5.0" + checksum: 10/60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946 languageName: node linkType: hard @@ -32547,13 +32523,6 @@ __metadata: languageName: node linkType: hard -"get-stdin@npm:^9.0.0": - version: 9.0.0 - resolution: "get-stdin@npm:9.0.0" - checksum: 10/5972bc34d05932b45512c8e2d67b040f1c1ca8afb95c56cbc480985f2d761b7e37fe90dc8abd22527f062cc5639a6930ff346e9952ae4c11a2d4275869459594 - languageName: node - linkType: hard - "get-stream@npm:^4.0.0, get-stream@npm:^4.1.0": version: 4.1.0 resolution: "get-stream@npm:4.1.0" @@ -32579,13 +32548,6 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^8.0.1": - version: 8.0.1 - resolution: "get-stream@npm:8.0.1" - checksum: 10/dde5511e2e65a48e9af80fea64aff11b4921b14b6e874c6f8294c50975095af08f41bfb0b680c887f28b566dd6ec2cb2f960f9d36a323359be324ce98b766e9e - languageName: node - linkType: hard - "get-stream@npm:^9.0.1": version: 9.0.1 resolution: "get-stream@npm:9.0.1" @@ -32644,10 +32606,10 @@ __metadata: languageName: node linkType: hard -"git-hooks-list@npm:^3.0.0": - version: 3.1.0 - resolution: "git-hooks-list@npm:3.1.0" - checksum: 10/05cbdb29e1e14f3b6fde78c876a34383e4476b1be32e8486ad03293f01add884c1a8df8c2dce2ca5d99119c94951b2ff9fa9cbd51d834ae6477b6813cefb998f +"git-hooks-list@npm:^4.1.1": + version: 4.2.1 + resolution: "git-hooks-list@npm:4.2.1" + checksum: 10/39449520045539c03b1d45d3a010424849152d6f723b638c6f19cb8c8b0993bb30ed5ef09653f4d1c1356f5fb51a396059096d8bdfcb6ca7ccb54f6e26efc338 languageName: node linkType: hard @@ -32875,17 +32837,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:^15.11.0": - version: 15.15.0 - resolution: "globals@npm:15.15.0" - checksum: 10/7f561c87b2fd381b27fc2db7df8a4ea7a9bb378667b8a7193e61fd2ca3a876479174e2a303a74345fbea6e1242e16db48915c1fd3bf35adcf4060a795b425e18 - languageName: node - linkType: hard - -"globals@npm:^17.3.0": - version: 17.3.0 - resolution: "globals@npm:17.3.0" - checksum: 10/44ba2b7db93eb6a2531dfba09219845e21f2e724a4f400eb59518b180b7d5bcf7f65580530e3d3023d7dc2bdbacf5d265fd87c393f567deb9a2b0472b51c9d5e +"globals@npm:^17.0.0, globals@npm:^17.3.0": + version: 17.4.0 + resolution: "globals@npm:17.4.0" + checksum: 10/ffad244617e94efcb3da72b7beefc941167c21316148ce378f322db7af72db06468f370e23224b3c7b17b5173a7c75b134e5e7b0949f2828519054a76892508d languageName: node linkType: hard @@ -32920,20 +32875,6 @@ __metadata: languageName: node linkType: hard -"globby@npm:^14.0.2": - version: 14.0.2 - resolution: "globby@npm:14.0.2" - dependencies: - "@sindresorhus/merge-streams": "npm:^2.1.0" - fast-glob: "npm:^3.3.2" - ignore: "npm:^5.2.4" - path-type: "npm:^5.0.0" - slash: "npm:^5.1.0" - unicorn-magic: "npm:^0.1.0" - checksum: 10/67660da70fc1223f7170c1a62ba6c373385e9e39765d952b6518606dec15ed8c7958e9dae6ba5752a31dbc1e9126f146938b830ad680fe794141734ffc3fbb75 - languageName: node - linkType: hard - "globrex@npm:^0.1.2": version: 0.1.2 resolution: "globrex@npm:0.1.2" @@ -33427,7 +33368,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.2": +"hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -34054,13 +33995,6 @@ __metadata: languageName: node linkType: hard -"human-signals@npm:^5.0.0": - version: 5.0.0 - resolution: "human-signals@npm:5.0.0" - checksum: 10/30f8870d831cdcd2d6ec0486a7d35d49384996742052cee792854273fa9dd9e7d5db06bb7985d4953e337e10714e994e0302e90dc6848069171b05ec836d65b0 - languageName: node - linkType: hard - "humanize-duration@npm:^3.25.1": version: 3.33.2 resolution: "humanize-duration@npm:3.33.2" @@ -34202,7 +34136,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.1.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4": +"ignore@npm:^5.1.4, ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10/cceb6a457000f8f6a50e1196429750d782afce5680dd878aa4221bd79972d68b3a55b4b1458fc682be978f4d3c6a249046aa0880637367216444ab7b014cfc98 @@ -34709,7 +34643,7 @@ __metadata: languageName: node linkType: hard -"is-callable@npm:^1.1.5, is-callable@npm:^1.2.7": +"is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" checksum: 10/48a9297fb92c99e9df48706241a189da362bff3003354aea4048bd5f7b2eb0d823cd16d0a383cece3d76166ba16d85d9659165ac6fcce1ac12e6c649d66dbdb9 @@ -34838,19 +34772,12 @@ __metadata: languageName: node linkType: hard -"is-fullwidth-code-point@npm:^4.0.0": - version: 4.0.0 - resolution: "is-fullwidth-code-point@npm:4.0.0" - checksum: 10/8ae89bf5057bdf4f57b346fb6c55e9c3dd2549983d54191d722d5c739397a903012cc41a04ee3403fd872e811243ef91a7c5196da7b5841dc6b6aae31a264a8d - languageName: node - linkType: hard - -"is-fullwidth-code-point@npm:^5.0.0": - version: 5.0.0 - resolution: "is-fullwidth-code-point@npm:5.0.0" +"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0": + version: 5.1.0 + resolution: "is-fullwidth-code-point@npm:5.1.0" dependencies: - get-east-asian-width: "npm:^1.0.0" - checksum: 10/8dfb2d2831b9e87983c136f5c335cd9d14c1402973e357a8ff057904612ed84b8cba196319fabedf9aefe4639e14fe3afe9d9966d1d006ebeb40fe1fed4babe5 + get-east-asian-width: "npm:^1.3.1" + checksum: 10/4700d8a82cb71bd2a2955587b2823c36dc4660eadd4047bfbd070821ddbce8504fc5f9b28725567ecddf405b1e06c6692c9b719f65df6af9ec5262bc11393a6a languageName: node linkType: hard @@ -35018,13 +34945,6 @@ __metadata: languageName: node linkType: hard -"is-path-cwd@npm:^3.0.0": - version: 3.0.0 - resolution: "is-path-cwd@npm:3.0.0" - checksum: 10/bc34d13b6a03dfca4a3ab6a8a5ba78ae4b24f4f1db4b2b031d2760c60d0913bd16a4b980dcb4e590adfc906649d5f5132684079a3972bd219da49deebb9adea8 - languageName: node - linkType: hard - "is-path-inside@npm:^3.0.2, is-path-inside@npm:^3.0.3": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" @@ -35032,13 +34952,6 @@ __metadata: languageName: node linkType: hard -"is-path-inside@npm:^4.0.0": - version: 4.0.0 - resolution: "is-path-inside@npm:4.0.0" - checksum: 10/8810fa11c58e6360b82c3e0d6cd7d9c7d0392d3ac9eb10f980b81f9839f40ac6d1d6d6f05d069db0d227759801228f0b072e1b6c343e4469b065ab5fe0b68fe5 - languageName: node - linkType: hard - "is-plain-obj@npm:^3.0.0": version: 3.0.0 resolution: "is-plain-obj@npm:3.0.0" @@ -35208,13 +35121,6 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^3.0.0": - version: 3.0.0 - resolution: "is-stream@npm:3.0.0" - checksum: 10/172093fe99119ffd07611ab6d1bcccfe8bc4aa80d864b15f43e63e54b7abc71e779acd69afdb854c4e2a67fdc16ae710e370eda40088d1cfc956a50ed82d8f16 - languageName: node - linkType: hard - "is-stream@npm:^4.0.1": version: 4.0.1 resolution: "is-stream@npm:4.0.1" @@ -35354,13 +35260,6 @@ __metadata: languageName: node linkType: hard -"is@npm:^3.2.1, is@npm:^3.3.0": - version: 3.3.0 - resolution: "is@npm:3.3.0" - checksum: 10/f77dc5a05a1e8fd1f1de282add9bb01c44dae27af72b883bf0ce342151dec48f125b0b8923efa78c1e93c4fb866095629b2c7de3e5e3853aea4ed17c82c5cd8d - languageName: node - linkType: hard - "isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" @@ -36436,19 +36335,6 @@ __metadata: languageName: node linkType: hard -"json-file-plus@npm:^3.3.1": - version: 3.3.1 - resolution: "json-file-plus@npm:3.3.1" - dependencies: - is: "npm:^3.2.1" - node.extend: "npm:^2.0.0" - object.assign: "npm:^4.1.0" - promiseback: "npm:^2.0.2" - safer-buffer: "npm:^2.0.2" - checksum: 10/6b71dad39e0fd8d0a23a82ca70b7c94adfcd59986e63165935d2adba5502076b75f3267e357372dd118f9d680ecc142f0f67617de9f27139c3c8b113cdd9c574 - languageName: node - linkType: hard - "json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1": version: 2.3.1 resolution: "json-parse-even-better-errors@npm:2.3.1" @@ -37215,13 +37101,6 @@ __metadata: languageName: node linkType: hard -"lilconfig@npm:^3.1.3": - version: 3.1.3 - resolution: "lilconfig@npm:3.1.3" - checksum: 10/b932ce1af94985f0efbe8896e57b1f814a48c8dbd7fc0ef8469785c6303ed29d0090af3ccad7e36b626bfca3a4dc56cc262697e9a8dd867623cf09a39d54e4c3 - languageName: node - linkType: hard - "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -37255,23 +37134,19 @@ __metadata: languageName: node linkType: hard -"lint-staged@npm:^15.0.0": - version: 15.5.2 - resolution: "lint-staged@npm:15.5.2" +"lint-staged@npm:^16.0.0": + version: 16.3.3 + resolution: "lint-staged@npm:16.3.3" dependencies: - chalk: "npm:^5.4.1" - commander: "npm:^13.1.0" - debug: "npm:^4.4.0" - execa: "npm:^8.0.1" - lilconfig: "npm:^3.1.3" - listr2: "npm:^8.2.5" + commander: "npm:^14.0.3" + listr2: "npm:^9.0.5" micromatch: "npm:^4.0.8" - pidtree: "npm:^0.6.0" string-argv: "npm:^0.3.2" - yaml: "npm:^2.7.0" + tinyexec: "npm:^1.0.2" + yaml: "npm:^2.8.2" bin: lint-staged: bin/lint-staged.js - checksum: 10/523c332d6cb6e34972a6530a7a2487307555e784df9466c82f2b8d17c8090a3db561a6362065ae6b63048c25fcb85c9e32057cd0bfb756bf7ab185bea1dbb89c + checksum: 10/64b20a0aa8710a89db49b4d4384f44e471de8544133cd9f80b1ebeac148144e43b1553aea2b34bf530fd32b554374ee8128c9332033ba138f1a9685964de1cd3 languageName: node linkType: hard @@ -37282,17 +37157,17 @@ __metadata: languageName: node linkType: hard -"listr2@npm:^8.2.5": - version: 8.2.5 - resolution: "listr2@npm:8.2.5" +"listr2@npm:^9.0.5": + version: 9.0.5 + resolution: "listr2@npm:9.0.5" dependencies: - cli-truncate: "npm:^4.0.0" + cli-truncate: "npm:^5.0.0" colorette: "npm:^2.0.20" eventemitter3: "npm:^5.0.1" log-update: "npm:^6.1.0" rfdc: "npm:^1.4.1" wrap-ansi: "npm:^9.0.0" - checksum: 10/c76542f18306195e464fe10203ee679a7beafa9bf0dc679ebacb416387cca8f5307c1d8ba35483d26ba611dc2fac5a1529733dce28f2660556082fb7eebb79f9 + checksum: 10/b78ffd60443aed9a8e0fc9162eb941ea4d63210700d61a895eb29348f23fc668327e26bbca87a9e6a6208e7fa96c475fe1f1c6c19b46f376f547e0cba3b935ae languageName: node linkType: hard @@ -39047,13 +38922,6 @@ __metadata: languageName: node linkType: hard -"mimic-fn@npm:^4.0.0": - version: 4.0.0 - resolution: "mimic-fn@npm:4.0.0" - checksum: 10/995dcece15ee29aa16e188de6633d43a3db4611bcf93620e7e62109ec41c79c0f34277165b8ce5e361205049766e371851264c21ac64ca35499acb5421c2ba56 - languageName: node - linkType: hard - "mimic-function@npm:^5.0.0": version: 5.0.1 resolution: "mimic-function@npm:5.0.1" @@ -39090,14 +38958,14 @@ __metadata: linkType: hard "mini-css-extract-plugin@npm:^2.4.2": - version: 2.10.0 - resolution: "mini-css-extract-plugin@npm:2.10.0" + version: 2.10.1 + resolution: "mini-css-extract-plugin@npm:2.10.1" dependencies: schema-utils: "npm:^4.0.0" tapable: "npm:^2.2.1" peerDependencies: webpack: ^5.0.0 - checksum: 10/bae5350ab82171c6c9a22a4397df14aa69280f5ff0e1ff4d2429ea841bc096927b1e27ba7b75a9c3dd77bd44bab449d6197bd748381f1326cbc8befcb10d1a9e + checksum: 10/2d0cecc3bea85cd7f9b1ce0974f1672976d610a9267e2988ff19f5d03b017bff12b32151a412de0f519a70be7d3b050b499b20101445fb21728cc2d35dd4041a languageName: node linkType: hard @@ -40282,16 +40150,6 @@ __metadata: languageName: node linkType: hard -"node.extend@npm:^2.0.0": - version: 2.0.3 - resolution: "node.extend@npm:2.0.3" - dependencies: - hasown: "npm:^2.0.0" - is: "npm:^3.3.0" - checksum: 10/f500ace16d0b90e9db3919676de593eb37e7b82d8d9b67d95a40e5856ef5842592df3364b4d01fc2c3f4c0dea6dd9d627444dd85fe18581b7a22caad5ffab249 - languageName: node - linkType: hard - "nodemailer@npm:^7.0.7": version: 7.0.13 resolution: "nodemailer@npm:7.0.13" @@ -40537,15 +40395,6 @@ __metadata: languageName: node linkType: hard -"npm-run-path@npm:^5.1.0": - version: 5.1.0 - resolution: "npm-run-path@npm:5.1.0" - dependencies: - path-key: "npm:^4.0.0" - checksum: 10/dc184eb5ec239d6a2b990b43236845332ef12f4e0beaa9701de724aa797fe40b6bbd0157fb7639d24d3ab13f5d5cf22d223a19c6300846b8126f335f788bee66 - languageName: node - linkType: hard - "npmlog@npm:^5.0.1": version: 5.0.1 resolution: "npmlog@npm:5.0.1" @@ -40677,7 +40526,7 @@ __metadata: languageName: node linkType: hard -"object.assign@npm:^4.1.0, object.assign@npm:^4.1.4, object.assign@npm:^4.1.7": +"object.assign@npm:^4.1.4, object.assign@npm:^4.1.7": version: 4.1.7 resolution: "object.assign@npm:4.1.7" dependencies: @@ -40814,15 +40663,6 @@ __metadata: languageName: node linkType: hard -"once@npm:~1.3.0": - version: 1.3.3 - resolution: "once@npm:1.3.3" - dependencies: - wrappy: "npm:1" - checksum: 10/8e832de08b1d73b470e01690c211cb4fcefccab1fd1bd19e706d572d74d3e9b7e38a8bfcdabdd364f9f868757d9e8e5812a59817dc473eaf698ff3bfae2219f2 - languageName: node - linkType: hard - "one-time@npm:^1.0.0": version: 1.0.0 resolution: "one-time@npm:1.0.0" @@ -40841,15 +40681,6 @@ __metadata: languageName: node linkType: hard -"onetime@npm:^6.0.0": - version: 6.0.0 - resolution: "onetime@npm:6.0.0" - dependencies: - mimic-fn: "npm:^4.0.0" - checksum: 10/0846ce78e440841335d4e9182ef69d5762e9f38aa7499b19f42ea1c4cd40f0b4446094c455c713f9adac3f4ae86f613bb5e30c99e52652764d06a89f709b3788 - languageName: node - linkType: hard - "onetime@npm:^7.0.0": version: 7.0.0 resolution: "onetime@npm:7.0.0" @@ -41396,10 +41227,10 @@ __metadata: languageName: node linkType: hard -"packageurl-js@npm:1.2.0": - version: 1.2.0 - resolution: "packageurl-js@npm:1.2.0" - checksum: 10/b780ad6cf9f75055effafe8fbed37617eb1924e3dc5b055fb3ecceaaaa93da73ea1508a3874b04bd13342a77bd852b70a4e52596c171cbc57840c4b8452d2d56 +"packageurl-js@npm:2.0.1": + version: 2.0.1 + resolution: "packageurl-js@npm:2.0.1" + checksum: 10/5fdb2b89e5c39dbb87806ef303bc558da0f8c178b8afb2647979d49212039f2cccc6c0135816819d061c6b12b47e8c6bb8c34a2b9fdd8684b9fb975dcf3bc73b languageName: node linkType: hard @@ -41811,13 +41642,6 @@ __metadata: languageName: node linkType: hard -"path-key@npm:^4.0.0": - version: 4.0.0 - resolution: "path-key@npm:4.0.0" - checksum: 10/8e6c314ae6d16b83e93032c61020129f6f4484590a777eed709c4a01b50e498822b00f76ceaf94bc64dbd90b327df56ceadce27da3d83393790f1219e07721d7 - languageName: node - linkType: hard - "path-parse@npm:^1.0.7": version: 1.0.7 resolution: "path-parse@npm:1.0.7" @@ -41880,13 +41704,6 @@ __metadata: languageName: node linkType: hard -"path-type@npm:^5.0.0": - version: 5.0.0 - resolution: "path-type@npm:5.0.0" - checksum: 10/15ec24050e8932c2c98d085b72cfa0d6b4eeb4cbde151a0a05726d8afae85784fc5544f733d8dfc68536587d5143d29c0bd793623fad03d7e61cc00067291cd5 - languageName: node - linkType: hard - "pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" @@ -42110,15 +41927,6 @@ __metadata: languageName: node linkType: hard -"pidtree@npm:^0.6.0": - version: 0.6.0 - resolution: "pidtree@npm:0.6.0" - bin: - pidtree: bin/pidtree.js - checksum: 10/ea67fb3159e170fd069020e0108ba7712df9f0fd13c8db9b2286762856ddce414fb33932e08df4bfe36e91fe860b51852aee49a6f56eb4714b69634343add5df - languageName: node - linkType: hard - "pify@npm:^2.3.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -42298,13 +42106,6 @@ __metadata: languageName: node linkType: hard -"pluralize@npm:^7.0.0": - version: 7.0.0 - resolution: "pluralize@npm:7.0.0" - checksum: 10/905274e679d3802650fdfdd977434757d4680082da7a23c0938a608d1d5c8246790b62dc15ff1f737b0d57baa6ad2f6ebb0857b1950435a583e32af76ee58e1f - languageName: node - linkType: hard - "pony-cause@npm:^1.1.1": version: 1.1.1 resolution: "pony-cause@npm:1.1.1" @@ -42928,13 +42729,6 @@ __metadata: languageName: node linkType: hard -"presentable-error@npm:^0.0.1": - version: 0.0.1 - resolution: "presentable-error@npm:0.0.1" - checksum: 10/013809ee7a47ced847a8d860e9b89a56cdd8c4f1ad04ad8da1e58fd60843f77f497d204146bb15aaa9793d3b94ad8626eed01256fc9eb5839a545af2000a5fa4 - languageName: node - linkType: hard - "prettier@npm:^2.2.1, prettier@npm:^2.7.1": version: 2.8.8 resolution: "prettier@npm:2.8.8" @@ -42944,7 +42738,7 @@ __metadata: languageName: node linkType: hard -"pretty-bytes@npm:^5.1.0, pretty-bytes@npm:^5.3.0": +"pretty-bytes@npm:^5.3.0": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" checksum: 10/9c082500d1e93434b5b291bd651662936b8bd6204ec9fa17d563116a192d6d86b98f6d328526b4e8d783c07d5499e2614a807520249692da9ec81564b2f439cd @@ -43085,15 +42879,6 @@ __metadata: languageName: node linkType: hard -"promise-deferred@npm:^2.0.3": - version: 2.0.4 - resolution: "promise-deferred@npm:2.0.4" - dependencies: - promise: "npm:^8.3.0" - checksum: 10/1d0e306d54a7436e288836c0784abdf11798011a6c3309f4ce8e24564ba958c41ca0d21bb7ec95386f04ac8f9691fdd8e3dd0af5176b496a2303d00db96acf5a - languageName: node - linkType: hard - "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -43141,25 +42926,6 @@ __metadata: languageName: node linkType: hard -"promise@npm:^8.3.0": - version: 8.3.0 - resolution: "promise@npm:8.3.0" - dependencies: - asap: "npm:~2.0.6" - checksum: 10/55e9d0d723c66810966bc055c6c77a3658c0af7e4a8cc88ea47aeaf2949ca0bd1de327d9c631df61236f5406ad478384fa19a77afb3f88c0303eba9e5eb0a8d8 - languageName: node - linkType: hard - -"promiseback@npm:^2.0.2": - version: 2.0.3 - resolution: "promiseback@npm:2.0.3" - dependencies: - is-callable: "npm:^1.1.5" - promise-deferred: "npm:^2.0.3" - checksum: 10/39716e64ac75b3a5c58532493f594d4788267ee13e2aeee5c60b448eb17e8f98c8ff4778c5497aed1594e29c428710ae21c83671c87c24b3d2c42f0c359d6e55 - languageName: node - linkType: hard - "prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" @@ -45487,7 +45253,7 @@ __metadata: languageName: node linkType: hard -"rollup-plugin-postcss@npm:*, rollup-plugin-postcss@npm:^4.0.0": +"rollup-plugin-postcss@npm:^4.0.0": version: 4.0.2 resolution: "rollup-plugin-postcss@npm:4.0.2" dependencies: @@ -45632,6 +45398,7 @@ __metadata: resolution: "root@workspace:." dependencies: "@backstage/cli": "workspace:*" + "@backstage/cli-defaults": "workspace:*" "@backstage/codemods": "workspace:*" "@backstage/create-app": "workspace:*" "@backstage/e2e-test-utils": "workspace:*" @@ -45674,7 +45441,7 @@ __metadata: jest: "npm:^30" js-yaml: "npm:^4.1.1" jsdom: "npm:^27" - lint-staged: "npm:^15.0.0" + lint-staged: "npm:^16.0.0" madge: "npm:^8.0.0" minimist: "npm:^1.2.5" node-gyp: "npm:^10.0.0" @@ -45682,7 +45449,7 @@ __metadata: semver: "npm:^7.5.3" shx: "npm:^0.4.0" sloc: "npm:^0.3.1" - sort-package-json: "npm:^2.8.0" + sort-package-json: "npm:^3.0.0" storybook: "npm:^10.3.0-alpha.1" ts-morph: "npm:^24.0.0" typedoc: "npm:^0.28.0" @@ -45873,7 +45640,7 @@ __metadata: languageName: node linkType: hard -"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:^2.0.2, safer-buffer@npm:~2.1.0": +"safer-buffer@npm:>= 2.1.2 < 3, safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" checksum: 10/7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83 @@ -46587,13 +46354,6 @@ __metadata: languageName: node linkType: hard -"slash@npm:^5.1.0": - version: 5.1.0 - resolution: "slash@npm:5.1.0" - checksum: 10/2c41ec6fb1414cd9bba0fa6b1dd00e8be739e3fe85d079c69d4b09ca5f2f86eafd18d9ce611c0c0f686428638a36c272a6ac14799146a8295f259c10cc45cde4 - languageName: node - linkType: hard - "slice-ansi@npm:^3.0.0": version: 3.0.0 resolution: "slice-ansi@npm:3.0.0" @@ -46616,16 +46376,6 @@ __metadata: languageName: node linkType: hard -"slice-ansi@npm:^5.0.0": - version: 5.0.0 - resolution: "slice-ansi@npm:5.0.0" - dependencies: - ansi-styles: "npm:^6.0.0" - is-fullwidth-code-point: "npm:^4.0.0" - checksum: 10/7e600a2a55e333a21ef5214b987c8358fe28bfb03c2867ff2cbf919d62143d1812ac27b4297a077fdaf27a03da3678e49551c93e35f9498a3d90221908a1180e - languageName: node - linkType: hard - "slice-ansi@npm:^7.1.0": version: 7.1.0 resolution: "slice-ansi@npm:7.1.0" @@ -46636,6 +46386,16 @@ __metadata: languageName: node linkType: hard +"slice-ansi@npm:^8.0.0": + version: 8.0.0 + resolution: "slice-ansi@npm:8.0.0" + dependencies: + ansi-styles: "npm:^6.2.3" + is-fullwidth-code-point: "npm:^5.1.0" + checksum: 10/6a7e146852047e26dd5857b35c767e52906549c580cce0ad2287cc32f54f5a582494f674817fc9ac21b2e4ac1ddeaa85b3dee409782681b465330278890c73a8 + languageName: node + linkType: hard + "sloc@npm:^0.3.1": version: 0.3.2 resolution: "sloc@npm:0.3.2" @@ -46685,14 +46445,14 @@ __metadata: languageName: node linkType: hard -"snyk-nodejs-lockfile-parser@npm:^1.58.14": - version: 1.60.1 - resolution: "snyk-nodejs-lockfile-parser@npm:1.60.1" +"snyk-nodejs-lockfile-parser@npm:^2.0.0": + version: 2.6.1 + resolution: "snyk-nodejs-lockfile-parser@npm:2.6.1" dependencies: - "@snyk/dep-graph": "npm:^2.3.0" + "@snyk/dep-graph": "npm:^2.12.0" "@snyk/error-catalog-nodejs-public": "npm:^5.16.0" "@snyk/graphlib": "npm:2.1.9-patch.3" - "@yarnpkg/core": "npm:^2.4.0" + "@yarnpkg/core": "npm:^4.4.1" "@yarnpkg/lockfile": "npm:^1.1.0" dependency-path: "npm:^9.2.8" event-loop-spinner: "npm:^2.0.0" @@ -46709,7 +46469,7 @@ __metadata: uuid: "npm:^8.3.0" bin: parse-nodejs-lockfile: bin/index.js - checksum: 10/2186abf1a7930ff12c5a3929c514300a0ecb47f576f64b84b265292c106aba9ec13a98cdae0b2f01449e53311d361a67cbb13ed631e718d28faaafa97971adc5 + checksum: 10/6573f7379f0005f632678b8518fd5559f91548fc3fb5f2004a92defb44e99acc46097382c7017f6f901df89733e521486229b5fcf9e6d1715b508ca1f9d46fff languageName: node linkType: hard @@ -46777,28 +46537,27 @@ __metadata: languageName: node linkType: hard -"sort-object-keys@npm:^1.1.3": - version: 1.1.3 - resolution: "sort-object-keys@npm:1.1.3" - checksum: 10/abea944d6722a1710a1aa6e4f9509da085d93d5fc0db23947cb411eedc7731f80022ce8fa68ed83a53dd2ac7441fcf72a3f38c09b3d9bbc4ff80546aa2e151ad +"sort-object-keys@npm:^2.0.1": + version: 2.1.0 + resolution: "sort-object-keys@npm:2.1.0" + checksum: 10/a0a18a6f4ab601adb889204a83e2664a4810042dd9680e588cabb47fffd40473939ce3fda5a28fdb58bac0762a7b45ad442815465b978d4b243bab840df3d7cd languageName: node linkType: hard -"sort-package-json@npm:^2.8.0": - version: 2.15.1 - resolution: "sort-package-json@npm:2.15.1" +"sort-package-json@npm:^3.0.0": + version: 3.6.1 + resolution: "sort-package-json@npm:3.6.1" dependencies: - detect-indent: "npm:^7.0.1" - detect-newline: "npm:^4.0.0" - get-stdin: "npm:^9.0.0" - git-hooks-list: "npm:^3.0.0" + detect-indent: "npm:^7.0.2" + detect-newline: "npm:^4.0.1" + git-hooks-list: "npm:^4.1.1" is-plain-obj: "npm:^4.1.0" - semver: "npm:^7.6.0" - sort-object-keys: "npm:^1.1.3" - tinyglobby: "npm:^0.2.9" + semver: "npm:^7.7.3" + sort-object-keys: "npm:^2.0.1" + tinyglobby: "npm:^0.2.15" bin: sort-package-json: cli.js - checksum: 10/3378565a07368e00eeb625e6b85d1edf9a3bf9f88ced32423bd3036ddb8c674fb8c0fb559044ef939e6de20bb7550423e992f4abd19cff2398d006f0fe8afc82 + checksum: 10/e4bcf8432b570143e94b6b302fc941fd0b564e8f9f423bc55ae469c81e6f84c35e456f024d8d186a2ec6ca120407444e6ceb5a5f0feb7c877c159ef716f651ed languageName: node linkType: hard @@ -47245,7 +47004,7 @@ __metadata: languageName: node linkType: hard -"stream-to-array@npm:^2.3.0, stream-to-array@npm:~2.3.0": +"stream-to-array@npm:^2.3.0": version: 2.3.0 resolution: "stream-to-array@npm:2.3.0" dependencies: @@ -47254,17 +47013,6 @@ __metadata: languageName: node linkType: hard -"stream-to-promise@npm:^2.2.0": - version: 2.2.0 - resolution: "stream-to-promise@npm:2.2.0" - dependencies: - any-promise: "npm:~1.3.0" - end-of-stream: "npm:~1.1.0" - stream-to-array: "npm:~2.3.0" - checksum: 10/e4d3253c68dae65c51c5aa1bd657a072267869fd61b57068e74cee7a8e45d67fe154b56918cf546b38cb5be1fa042e632b7267abc9676bb75bba55952d2d57d1 - languageName: node - linkType: hard - "streamroller@npm:^3.1.5": version: 3.1.5 resolution: "streamroller@npm:3.1.5" @@ -47388,6 +47136,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.2.0": + version: 8.2.0 + resolution: "string-width@npm:8.2.0" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10/c4f62877ec08fca155e84a260eb4f58f473cfe5169bd1c1e21ffb563d8e0b7f6d705cc3d250f2ed6bb4f30ee9732ad026f54afaac77aa487e3d1dc1b1969e51b + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.1": version: 2.0.1 resolution: "string.prototype.includes@npm:2.0.1" @@ -47515,7 +47273,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": version: 7.2.0 resolution: "strip-ansi@npm:7.2.0" dependencies: @@ -47580,13 +47338,6 @@ __metadata: languageName: node linkType: hard -"strip-final-newline@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-final-newline@npm:3.0.0" - checksum: 10/23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050 - languageName: node - linkType: hard - "strip-indent@npm:^3.0.0": version: 3.0.0 resolution: "strip-indent@npm:3.0.0" @@ -48201,7 +47952,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:*, terser-webpack-plugin@npm:^5.1.3, terser-webpack-plugin@npm:^5.3.17": +"terser-webpack-plugin@npm:^5.3.17": version: 5.3.17 resolution: "terser-webpack-plugin@npm:5.3.17" dependencies: @@ -48428,6 +48179,13 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^1.0.2": + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10/cb709ed4240e873d3816e67f851d445f5676e0ae3a52931a60ff571d93d388da09108c8057b62351766133ee05ff3159dd56c3a0fbd39a5933c6639ce8771405 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.9": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -48927,7 +48685,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.13.0, tslib@npm:^1.14.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3": +"tslib@npm:^1.14.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: 10/7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb @@ -49445,9 +49203,9 @@ __metadata: linkType: hard "undici@npm:^7.2.3, undici@npm:^7.22.0": - version: 7.22.0 - resolution: "undici@npm:7.22.0" - checksum: 10/a7a1813ba4b74c0d46cc8dd160386202c05699ffc487c5d882cf40e6d2435c8d6faff3b8f8675d09bd1ef0386e370675c26b59b9a8c8b3f17b9f82a42236a927 + version: 7.24.4 + resolution: "undici@npm:7.24.4" + checksum: 10/747e76e0fd685ae1bb6fc1a2ebce0caca4ee8bd5599a77da36a3f94eac146987a9547bdbec7a74d18c0776df8ad348dccb4209901ca83fc4076f560de0d5dc7a languageName: node linkType: hard @@ -49460,13 +49218,6 @@ __metadata: languageName: node linkType: hard -"unicorn-magic@npm:^0.1.0": - version: 0.1.0 - resolution: "unicorn-magic@npm:0.1.0" - checksum: 10/9b4d0e9809807823dc91d0920a4a4c0cff2de3ebc54ee87ac1ee9bc75eafd609b09d1f14495e0173aef26e01118706196b6ab06a75fe0841028b3983a8af313f - languageName: node - linkType: hard - "unified@npm:^10.0.0": version: 10.1.0 resolution: "unified@npm:10.1.0" @@ -51217,18 +50968,18 @@ __metadata: fs-extra: "npm:^11.2.0" nodemon: "npm:^3.0.1" semver: "npm:^7.6.0" - snyk-nodejs-lockfile-parser: "npm:^1.58.14" + snyk-nodejs-lockfile-parser: "npm:^2.0.0" yaml: "npm:^2.0.0" languageName: unknown linkType: soft "yauzl@npm:^3.0.0": - version: 3.2.0 - resolution: "yauzl@npm:3.2.0" + version: 3.2.1 + resolution: "yauzl@npm:3.2.1" dependencies: buffer-crc32: "npm:~0.2.3" pend: "npm:~1.2.0" - checksum: 10/a3cd2bfcf7590673bb35750f2a4e5107e3cc939d32d98a072c0673fe42329e390f471b4a53dbbd72512229099b18aa3b79e6ddb87a73b3a17446080c903a2c4b + checksum: 10/15dfae75fbfe59c6a1b7a2cb27a995cda0ee70549d32d6b19937e84897436170f169f6bbefc34b9e9beb9c9114a1b8a8a40e7687a907909a19681ebcbf35a1f3 languageName: node linkType: hard