diff --git a/.changeset/add-actions-cli-module.md b/.changeset/add-actions-cli-module.md
new file mode 100644
index 0000000000..538509547f
--- /dev/null
+++ b/.changeset/add-actions-cli-module.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-module-actions': patch
+---
+
+Added `actions` CLI module for listing and executing actions from the distributed actions registry. Includes `actions list`, `actions execute`, and `actions sources` commands for managing plugin sources.
diff --git a/.changeset/add-cli-module-template-support.md b/.changeset/add-cli-module-template-support.md
new file mode 100644
index 0000000000..be965d882d
--- /dev/null
+++ b/.changeset/add-cli-module-template-support.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-module-new': patch
+---
+
+Added support for the `cli-module` template role for scaffolding new CLI module packages.
diff --git a/.changeset/add-cli-module-template.md b/.changeset/add-cli-module-template.md
new file mode 100644
index 0000000000..f6b169906f
--- /dev/null
+++ b/.changeset/add-cli-module-template.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Added a new `cli-module` template for creating CLI module packages.
diff --git a/.changeset/allow-user-action-invocation.md b/.changeset/allow-user-action-invocation.md
new file mode 100644
index 0000000000..d9ac46a60e
--- /dev/null
+++ b/.changeset/allow-user-action-invocation.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-defaults': minor
+---
+
+The actions registry invoke endpoint now accepts direct user credentials in addition to service principals, enabling CLI and other direct user clients to invoke actions.
diff --git a/.changeset/api-ref-plugin-owner-app.md b/.changeset/api-ref-plugin-owner-app.md
new file mode 100644
index 0000000000..e9727a3255
--- /dev/null
+++ b/.changeset/api-ref-plugin-owner-app.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Frontend apps now respect an explicit `pluginId` on `ApiRef`s when deciding which plugin owns an API factory.
diff --git a/.changeset/api-ref-plugin-owner-core.md b/.changeset/api-ref-plugin-owner-core.md
new file mode 100644
index 0000000000..005dc06424
--- /dev/null
+++ b/.changeset/api-ref-plugin-owner-core.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-plugin-api': patch
+---
+
+Updated `createApiRef` to preserve the direct config call without deprecation warnings while staying compatible with the new frontend API ref typing.
diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md
new file mode 100644
index 0000000000..0b6dc46ac3
--- /dev/null
+++ b/.changeset/auth-module-exports.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-module-auth': patch
+---
+
+Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`.
diff --git a/.changeset/cli-defaults-actions.md b/.changeset/cli-defaults-actions.md
new file mode 100644
index 0000000000..1c46f96ead
--- /dev/null
+++ b/.changeset/cli-defaults-actions.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-defaults': patch
+---
+
+Added `@backstage/cli-module-actions` to the default set of CLI modules.
diff --git a/.changeset/core-compat-api-filter-predicates-dependency.md b/.changeset/core-compat-api-filter-predicates-dependency.md
new file mode 100644
index 0000000000..5b99e9c79c
--- /dev/null
+++ b/.changeset/core-compat-api-filter-predicates-dependency.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-compat-api': patch
+---
+
+Added a missing dependency on `@backstage/filter-predicates` to `@backstage/core-compat-api`. This fixes package metadata for consumers that use compatibility helpers relying on filter predicate support.
diff --git a/.changeset/core-components-progress-accessibility.md b/.changeset/core-components-progress-accessibility.md
new file mode 100644
index 0000000000..c4f53ccd2d
--- /dev/null
+++ b/.changeset/core-components-progress-accessibility.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default.
diff --git a/.changeset/fix-api-review-frontend-app-api.md b/.changeset/fix-api-review-frontend-app-api.md
new file mode 100644
index 0000000000..ff287ac2a5
--- /dev/null
+++ b/.changeset/fix-api-review-frontend-app-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Removed `@backstage/core-plugin-api` leakage from the public API surface. All types such as `ApiHolder` and `ConfigApi` are now imported from `@backstage/frontend-plugin-api`.
diff --git a/.changeset/fix-api-review-frontend-plugin-api.md b/.changeset/fix-api-review-frontend-plugin-api.md
new file mode 100644
index 0000000000..10458d8ebb
--- /dev/null
+++ b/.changeset/fix-api-review-frontend-plugin-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': minor
+---
+
+Renamed `PluginOptions` to `CreateFrontendPluginOptions` and deprecated the old name. Removed `ResolvedExtensionInputs` from the main entry point; it is still available as an inline type in extension factory signatures.
diff --git a/.changeset/fix-checkbox-empty-label-gap.md b/.changeset/fix-checkbox-empty-label-gap.md
new file mode 100644
index 0000000000..323d8e7bab
--- /dev/null
+++ b/.changeset/fix-checkbox-empty-label-gap.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Made Checkbox `children` optional and added a dev warning when neither a visible label, `aria-label`, nor `aria-labelledby` is provided. The label wrapper div is no longer rendered when there are no children, removing the unnecessary gap.
+
+**Affected components:** Checkbox
diff --git a/.changeset/fix-table-row-bg-consumer.md b/.changeset/fix-table-row-bg-consumer.md
new file mode 100644
index 0000000000..3c8d64198c
--- /dev/null
+++ b/.changeset/fix-table-row-bg-consumer.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed Table row hover, selected, pressed, and disabled background states to use the correct neutral token level based on the container background.
+
+**Affected components:** Table
diff --git a/.changeset/fix-table-row-hover-selection.md b/.changeset/fix-table-row-hover-selection.md
new file mode 100644
index 0000000000..2543b22389
--- /dev/null
+++ b/.changeset/fix-table-row-hover-selection.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Updated Table selection checkboxes to use `aria-label` instead of empty fragment children, improving accessibility and removing the unnecessary label gap in the selection cells.
+
+**Affected components:** Table
diff --git a/.changeset/frontend-test-utils-filter-predicates-dependency.md b/.changeset/frontend-test-utils-filter-predicates-dependency.md
new file mode 100644
index 0000000000..c8664d9673
--- /dev/null
+++ b/.changeset/frontend-test-utils-filter-predicates-dependency.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+Added a missing dependency on `@backstage/filter-predicates` to `@backstage/frontend-test-utils`. This fixes package metadata for consumers using the frontend test app helpers with predicate-based behavior.
diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md
new file mode 100644
index 0000000000..6ae300f556
--- /dev/null
+++ b/.changeset/opaque-api-ref-type.md
@@ -0,0 +1,7 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. The builder form now also preserves literal API ref IDs in the resulting `ApiRef` type.
+
+The `createApiRef().with({ ... })` form can also use an explicit `pluginId` to declare API ownership without encoding the plugin ID into the API ref ID, while keeping that metadata internal to runtime handling.
diff --git a/.changeset/permission-api-batching.md b/.changeset/permission-api-batching.md
new file mode 100644
index 0000000000..187eb2a3bc
--- /dev/null
+++ b/.changeset/permission-api-batching.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-permission-react': patch
+---
+
+Permission checks made in the same tick are now batched into a single call to the permission backend.
diff --git a/.changeset/plugin-app-bootstrap-phase.md b/.changeset/plugin-app-bootstrap-phase.md
new file mode 100644
index 0000000000..c3bab9e563
--- /dev/null
+++ b/.changeset/plugin-app-bootstrap-phase.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+Updated the default app root to better support phased app preparation by allowing the app layout to be absent during bootstrap, routing bootstrap failures through the app root boundary, and avoiding installation of a guest identity in protected apps that do not provide a sign-in page.
diff --git a/.changeset/prepare-specialized-app-frontend-app-api.md b/.changeset/prepare-specialized-app-frontend-app-api.md
new file mode 100644
index 0000000000..e62d025189
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-app-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. Utility APIs that are materialized during bootstrap are also frozen for the lifetime of the app instance, causing deferred overrides of those APIs to be ignored and reported as app errors.
diff --git a/.changeset/prepare-specialized-app-frontend-defaults.md b/.changeset/prepare-specialized-app-frontend-defaults.md
new file mode 100644
index 0000000000..12e153fc75
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-defaults.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-defaults': patch
+---
+
+Updated `createApp` to use the phased `prepareSpecializedApp` flow, allowing apps to render a bootstrap tree before the full app is finalized.
diff --git a/.changeset/prepare-specialized-app-frontend-plugin-api.md b/.changeset/prepare-specialized-app-frontend-plugin-api.md
new file mode 100644
index 0000000000..9933690f39
--- /dev/null
+++ b/.changeset/prepare-specialized-app-frontend-plugin-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Added support for `if` predicates on `createFrontendPlugin` and `createFrontendModule`, applying shared conditions to every extension in the feature. Plugin and extension overrides can now also replace or remove existing `if` predicates.
diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md
index 22457bcb30..3e7c760a42 100644
--- a/docs/frontend-system/architecture/10-app.md
+++ b/docs/frontend-system/architecture/10-app.md
@@ -48,6 +48,52 @@ Because feature discovery needs to interact with the compilation process, it is
For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md).
+## Preparing an App in Phases
+
+Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`.
+
+This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, lets you either subscribe to finalization with `onFinalized()` or finalize synchronously with `finalize()`, and lets you reuse a prepared session in a later app instance.
+
+```tsx
+import {
+ FinalizedSpecializedApp,
+ prepareSpecializedApp,
+} from '@backstage/frontend-app-api';
+
+const preparedApp = prepareSpecializedApp({
+ config,
+ features: [appPlugin, ...features],
+});
+
+const bootstrapApp = preparedApp.getBootstrapApp();
+
+const unsubscribe = preparedApp.onFinalized(
+ (finalizedApp: FinalizedSpecializedApp) => {
+ console.log(finalizedApp.sessionState);
+ },
+);
+```
+
+The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. If you call `onFinalized()`, you are subscribing to the bootstrap-owned finalization flow. In the sign-in case, the sign-in page receives an `onSignInSuccess` callback, and once it provides an identity through that callback the full app is finalized and `onFinalized()` subscribers are notified.
+
+If you instead call `finalize()`, you are taking ownership of finalization yourself. This only works when the app can be finalized synchronously, for example when all predicate context is already available or when you passed a reusable session state to `prepareSpecializedApp()` up front:
+
+```tsx
+const preparedApp = prepareSpecializedApp({
+ config,
+ features: [appPlugin, ...features],
+ advanced: {
+ sessionState,
+ },
+});
+
+const app = preparedApp.finalize();
+```
+
+When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings.
+
+Utility APIs that are first materialized during bootstrap are frozen for the lifetime of that app instance. Finalization may still add new APIs and may override existing API refs that were not materialized during bootstrap, but any deferred override of an already materialized bootstrap API is ignored and reported as an app error.
+
## Plugin Info Resolution
When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info).
diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md
index c2f5f237c3..d124a0e61d 100644
--- a/docs/frontend-system/architecture/15-plugins.md
+++ b/docs/frontend-system/architecture/15-plugins.md
@@ -76,6 +76,20 @@ These are the routes that the plugin exposes to the app. The `routes` option dec
This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`.
+### `if` option
+
+The `if` option lets you apply a shared condition to all extensions that are provided by a plugin instance. This is useful when you want to gate an entire plugin behind a feature flag or permission without repeating the same predicate on every individual extension.
+
+```tsx
+export default createFrontendPlugin({
+ pluginId: 'my-plugin',
+ if: { featureFlags: { $contains: 'my-plugin-enabled' } },
+ extensions: [...],
+});
+```
+
+This predicate is applied to every extension from that plugin instance. If any extension already has its own `if` predicate, the two are combined using logical `AND`.
+
### `info` option
This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources.
diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md
index 106ab5340a..3df33b9cad 100644
--- a/docs/frontend-system/architecture/20-extensions.md
+++ b/docs/frontend-system/architecture/20-extensions.md
@@ -41,6 +41,43 @@ Each extension in the app can be disabled, meaning it will not be instantiated a
The ordering of extensions is sometimes very important, as it may for example affect in which order they show up in the UI. When an extension is toggled from disabled to enabled through configuration it resets the ordering of the extension, pushing it to the end of the list. It is generally recommended to leave extensions as disabled by default if their order is important, allowing for the order in which their are enabled in the configuration to determine their order in the app.
+### Conditions
+
+Extensions can also be conditionally enabled by providing an `if` predicate. This is available both on `createExtension(...)` directly and when creating extensions from blueprints.
+
+The predicate uses the same `FilterPredicate` syntax as elsewhere in Backstage, but in the frontend system it is evaluated against app-level data such as `featureFlags` and `permissions`. For example, the following page is only installed when the `experimental-features` flag is active:
+
+```tsx
+const examplePage = PageBlueprint.make({
+ params: {
+ path: '/example',
+ loader: () => import('./ExamplePage').then(m => ),
+ },
+ if: { featureFlags: { $contains: 'experimental-features' } },
+});
+```
+
+You can also combine conditions using logical operators such as `$all`, `$any`, and `$not`:
+
+```tsx
+const guardedCard = CardBlueprint.make({
+ params: {
+ title: 'Guarded Card',
+ loader: () => import('./GuardedCard').then(m => ),
+ },
+ if: {
+ $all: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { permissions: { $contains: 'catalog.entity.create' } },
+ ],
+ },
+});
+```
+
+Conditions are evaluated when the app tree is prepared, not continuously while the app is running. If the underlying feature flags or permissions change, the app needs to be prepared again in order for the extension tree to change, which in practice typically means reloading the app.
+
+If a plugin or module also provides an `if` predicate, it is combined with the extension-level predicate using logical `AND`. See the [plugin `if` option](./15-plugins.md#if-option) and [frontend modules](./25-extension-overrides.md#creating-a-frontend-module) sections for more details.
+
### Configuration & configuration schema
Each extension can define a configuration schema that describes the configuration that it accepts. This schema is used to validate the configuration provided by integrators, but also to fill in default configuration values. The configuration itself is provided by integrators in order to customize the extension. It is not possible to provide a default configuration of an extension, this must instead be done through defaults in the configuration schema. This allows for a simpler configuration logic where multiple configurations of the same extension completely replace each other rather than being merged.
diff --git a/docs/frontend-system/architecture/23-extension-blueprints.md b/docs/frontend-system/architecture/23-extension-blueprints.md
index f6efccccbb..97eb01022d 100644
--- a/docs/frontend-system/architecture/23-extension-blueprints.md
+++ b/docs/frontend-system/architecture/23-extension-blueprints.md
@@ -9,7 +9,7 @@ The `createExtension` function and related APIs is considered a low-level buildi
## Creating an extension from a blueprint
-Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name` for the extension.
+Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name`, `attachTo`, `disabled`, or `if` predicate for the extension.
The following is a simple example of how one might use the blueprint `make` method to create a new extension:
diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md
index 5e684476c8..7afbd2bacb 100644
--- a/docs/frontend-system/architecture/25-extension-overrides.md
+++ b/docs/frontend-system/architecture/25-extension-overrides.md
@@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri
In general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides.
+Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension.
+
## Overriding an extension
Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead.
@@ -343,3 +345,5 @@ export default app.createRoot();
```
You must define a `pluginId` when creating a frontend module, and the plugin must also be installed for the module to be loaded.
+
+Frontend modules also support an `if` option. Just like for plugins, that predicate is applied to every extension that comes from the module, and is combined with any extension-level `if` predicate using logical `AND`. This is useful when you want to enable or disable an entire override package based on a feature flag or permission.
diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md
index bd9a0fbae5..cfef547afc 100644
--- a/docs/frontend-system/building-apps/01-index.md
+++ b/docs/frontend-system/building-apps/01-index.md
@@ -57,6 +57,8 @@ Note that `createRoot` returns the root element that is rendered by React. The a
Visit the [built-in extensions](#customize-or-override-built-in-extensions) section to see what is installed by default in a Backstage application.
+For advanced bootstrap flows that need access to the app tree before the full app is finalized, see [preparing an app in phases](../architecture/10-app.md#preparing-an-app-in-phases).
+
## Configure your app
### Bind external routes
diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md
index e1d0b0282d..05e60b54b7 100644
--- a/docs/frontend-system/building-apps/03-built-in-extensions.md
+++ b/docs/frontend-system/building-apps/03-built-in-extensions.md
@@ -99,7 +99,7 @@ This is the extension that creates the app root element, so it renders root leve
| ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| router | A React component that should manager the app routes context. | It must be one [router](https://reactrouter.com/en/main/routers/picking-a-router#web-projects) component or a custom component compatible with the 'react-router' library. | true | [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router) | [createRouterExtension](https://backstage.io/docs/reference/frontend-plugin-api.createrouterextension) |
| signInPage | A React component that should render the app sign-in page. | Should call the `onSignInSuccess` prop when the user has been successfully authorized, otherwise the user will not be correctly redirected to the application home page. | true | The default `AppRoot` extension does not use a default component for this input, it bypasses the user authentication check and always renders all routes when a login page is not installed. | [createSignInPageExtension](https://backstage.io/docs/reference/frontend-plugin-api.createsigninpageextension/) |
-| children | A React component that renders the app sidebar and main content in a particular layout. | - | false | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
+| children | A React component that renders the app sidebar and main content in a particular layout. | - | true | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
| elements | React elements to be rendered outside of the app layout, such as shared popups. | - | false | See [default elements](#default-app-root-elements-extensions). | [createAppRootElementExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootelementextension/) |
| wrappers | React components that should wrap the root element. | - | true | - | [createAppRootWrapperExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootwrapperextension/) |
diff --git a/lighthouserc.js b/lighthouserc.js
index c7c1275cdb..5273844c79 100644
--- a/lighthouserc.js
+++ b/lighthouserc.js
@@ -54,7 +54,7 @@ module.exports = {
preset: 'desktop',
},
startServerCommand: 'yarn start',
- startServerReadyPattern: 'webpack compiled successfully',
+ startServerReadyPattern: 'compiled.*successfully',
startServerReadyTimeout: 600000,
numberOfRuns: 1,
puppeteerScript: './.lighthouseci/scripts/guest-auth.js',
diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx
index 35426b589b..531d0084fb 100644
--- a/packages/app/src/examples/pagesPlugin.tsx
+++ b/packages/app/src/examples/pagesPlugin.tsx
@@ -23,6 +23,9 @@ import {
PageBlueprint,
FrontendPluginInfo,
useAppNode,
+ createExtensionBlueprint,
+ createExtensionInput,
+ coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
const page1Link = useRouteRef(page1RouteRef);
return (
+ The following pages demonstrate conditional extension enablement
+ via the if predicate using permissions. They will
+ only appear when the user has the required permissions.
+
+
+ Permission Card Example
+ {' '}
+ — a page that is always visible, but individual cards on it are
+ toggled by permissions
+
+
+
+
Feature Flag Enablement Examples
+
+ The following pages demonstrate conditional extension enablement
+ via the if predicate. They will only appear in the
+ router tree when their conditions are satisfied. Toggle the
+ relevant feature flags in Settings,
+ then refresh the app to see the pages appear.
+
+
+
+ Feature Flag Example —
+ requires the experimental-features flag
+
+
+ All Flags Example —
+ requires bothexperimental-features and{' '}
+ advanced-features ($all)
+
+
+ Any Flag Example — requires{' '}
+ eitherexperimental-features or{' '}
+ beta-access ($any)
+
+
+
);
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
},
});
+// Example: Page enabled only when a single feature flag is active.
+//
+// The `if` predicate is evaluated once at app startup (before the router
+// tree is built), so this page simply won't exist in the app until the flag is
+// toggled and the page is refreshed.
+//
+// To test: enable the 'experimental-features' flag in Settings, then refresh.
+const FeatureFlagPage = PageBlueprint.make({
+ name: 'featureFlagExample',
+ params: {
+ path: '/feature-flag-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Feature Flag Enabled Page
+
+ This page is only present in the app when the{' '}
+ experimental-features feature flag is active.
+
+ );
+ };
+ return ;
+ },
+ },
+ if: { featureFlags: { $contains: 'experimental-features' } },
+});
+
+// Example: Page enabled only when ALL of several feature flags are active.
+//
+// The $all operator requires every nested predicate to be satisfied. This page
+// won't appear unless both 'experimental-features' and 'advanced-features' are
+// enabled at the same time.
+//
+// To test: enable BOTH flags in Settings, then refresh.
+const AllFlagsPage = PageBlueprint.make({
+ name: 'allFlagsExample',
+ params: {
+ path: '/all-flags-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
All Flags Required Page
+
+ This page requires both{' '}
+ experimental-features and{' '}
+ advanced-features to be active simultaneously.
+
+
+ It uses a $all predicate to AND the two conditions
+ together.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: {
+ $all: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { featureFlags: { $contains: 'advanced-features' } },
+ ],
+ },
+});
+
+// Example: Page enabled when ANY one of several feature flags is active.
+//
+// The $any operator is satisfied as soon as at least one nested predicate
+// matches. Enabling either 'experimental-features' or 'beta-access' will make
+// this page appear.
+//
+// To test: enable at least one of the two flags in Settings, then refresh.
+const AnyFlagPage = PageBlueprint.make({
+ name: 'anyFlagExample',
+ params: {
+ path: '/any-flag-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Any Flag Sufficient Page
+
+ This page appears when either{' '}
+ experimental-features or beta-access is
+ active.
+
+
+ It uses a $any predicate to OR the two conditions
+ together.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: {
+ $any: [
+ { featureFlags: { $contains: 'experimental-features' } },
+ { featureFlags: { $contains: 'beta-access' } },
+ ],
+ },
+});
+
+// Blueprint for cards that attach to the PermissionCardPage below.
+//
+// Each card receives a title and description and renders a simple bordered card.
+// Individual card instances can be selectively enabled via the `if`
+// predicate, so only the cards the user is allowed to see will be instantiated.
+const PermissionExampleCardBlueprint = createExtensionBlueprint({
+ kind: 'permission-example-card',
+ attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
+ output: [coreExtensionData.reactElement],
+ *factory(params: { title: string; description: string }) {
+ yield coreExtensionData.reactElement(
+
+
{params.title}
+
{params.description}
+
,
+ );
+ },
+});
+
+// Example: Page with cards that are individually toggled by permissions.
+//
+// The page itself is always present. What changes is which cards are
+// instantiated inside it — each card declares its own `enabled` predicate
+// and is only wired into the page if that predicate is satisfied at startup.
+//
+// To test: make sure you do NOT have the catalog.entity.create permission and
+// refresh the page — the "Restricted Card" below should disappear.
+const PermissionCardPage = PageBlueprint.makeWithOverrides({
+ name: 'permissionCardExample',
+ inputs: {
+ cards: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ factory(originalFactory, { inputs }) {
+ return originalFactory({
+ path: '/permission-card-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ const cards = inputs.cards.map(card =>
+ card.get(coreExtensionData.reactElement),
+ );
+ return (
+
+
Permission-Gated Card Example
+
+ This page is always visible. The cards below are individually
+ gated — each one declares its own{' '}
+ {'if: { permissions: { $contains: "..." } }'}{' '}
+ predicate. Cards whose predicate fails are never instantiated,
+ so they simply won't appear here.
+
+
+ {cards.length > 0 ? (
+ cards
+ ) : (
+
+ No cards are visible — you may lack the required
+ permissions.
+
+ )}
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ });
+ },
+});
+
+// Always-visible card — no predicate, every user sees this.
+const PublicCard = PermissionExampleCardBlueprint.make({
+ name: 'public',
+ params: {
+ title: 'Public Card',
+ description: 'This card is visible to everyone regardless of permissions.',
+ },
+});
+
+// Permission-gated card — only instantiated when the user has
+// the catalog.entity.create permission.
+const RestrictedCard = PermissionExampleCardBlueprint.make({
+ name: 'restricted',
+ params: {
+ title: 'Restricted Card',
+ description:
+ 'This card is only visible to users who have the catalog.entity.create permission.',
+ },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
+});
+
+// Feature flag-gated card — only instantiated when the user has
+// the experimental-card FF enabled.
+const FeatureFlagCard = PermissionExampleCardBlueprint.make({
+ name: 'feature-flag',
+ params: {
+ title: 'Feature Flagged Card',
+ description: 'Visible only with the experimental-card FF active.',
+ },
+ if: { featureFlags: { $contains: 'experimental-card' } },
+});
+
+// Example: Page enabled only when the user is allowed to create catalog entities.
+//
+// The `if` predicate is evaluated once at app startup (after sign-in),
+// so this page simply won't exist in the router tree if the user lacks the
+// required permission.
+const PermissionGatedPage = PageBlueprint.make({
+ name: 'permissionGatedExample',
+ params: {
+ path: '/permission-gated-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ return (
+
+
Permission Gated Page
+
+ This page is only present when the user has the{' '}
+ catalog.entity.create permission.
+
+ {indexLink && Go back}
+
+ );
+ };
+ return ;
+ },
+ },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
+});
+
export const pagesPlugin = createFrontendPlugin({
pluginId: 'pages',
// routes: {
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
externalRoutes: {
pageX: externalPageXRouteRef,
},
- extensions: [IndexPage, Page1, ExternalPage],
+ featureFlags: [
+ { name: 'experimental-features' },
+ { name: 'advanced-features' },
+ { name: 'beta-access' },
+ { name: 'experimental-card' },
+ ],
+ extensions: [
+ IndexPage,
+ Page1,
+ ExternalPage,
+ FeatureFlagPage,
+ AllFlagsPage,
+ AnyFlagPage,
+ PermissionCardPage,
+ PublicCard,
+ RestrictedCard,
+ PermissionGatedPage,
+ FeatureFlagCard,
+ ],
});
diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts
index a2109ec1bf..73b5126ab5 100644
--- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts
+++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts
@@ -131,15 +131,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
'/.backstage/actions/v1/actions/:actionId/invoke',
async (req, res) => {
const credentials = await this.httpAuth.credentials(req);
- if (this.auth.isPrincipal(credentials, 'user')) {
- if (!credentials.principal.actor) {
- throw new NotAllowedError(
- `Actions must be invoked by a service, not a user`,
- );
- }
- } else if (this.auth.isPrincipal(credentials, 'none')) {
+ if (this.auth.isPrincipal(credentials, 'none')) {
throw new NotAllowedError(
- `Actions must be invoked by a service, not an anonymous request`,
+ `Actions must be invoked by an authenticated principal, not an anonymous request`,
);
}
diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts
index 56327031ec..6311976a41 100644
--- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts
+++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts
@@ -443,7 +443,7 @@ describe('actionsRegistryServiceFactory', () => {
});
});
- it('should throw an error if the action is invoked by a user', async () => {
+ it('should allow actions to be invoked by a user', async () => {
const testServices = [
actionsRegistryServiceFactory,
httpRouterServiceFactory,
@@ -464,12 +464,8 @@ describe('actionsRegistryServiceFactory', () => {
name: 'test',
});
- expect(status).toBe(403);
- expect(body).toMatchObject({
- error: {
- message: 'Actions must be invoked by a service, not a user',
- },
- });
+ expect(status).toBe(200);
+ expect(body).toMatchObject({ output: { ok: true } });
});
it('should validate the output of the action if provided', async () => {
diff --git a/packages/cli-defaults/package.json b/packages/cli-defaults/package.json
index 8a065fd4d7..7b1016d0d6 100644
--- a/packages/cli-defaults/package.json
+++ b/packages/cli-defaults/package.json
@@ -30,6 +30,7 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
+ "@backstage/cli-module-actions": "workspace:^",
"@backstage/cli-module-auth": "workspace:^",
"@backstage/cli-module-build": "workspace:^",
"@backstage/cli-module-config": "workspace:^",
diff --git a/packages/cli-defaults/src/index.ts b/packages/cli-defaults/src/index.ts
index dd7aed0c70..f167c3c424 100644
--- a/packages/cli-defaults/src/index.ts
+++ b/packages/cli-defaults/src/index.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import actions from '@backstage/cli-module-actions';
import auth from '@backstage/cli-module-auth';
import build from '@backstage/cli-module-build';
import config from '@backstage/cli-module-config';
@@ -31,6 +32,7 @@ import translations from '@backstage/cli-module-translations';
* @public
*/
export default [
+ actions,
auth,
build,
config,
diff --git a/packages/cli-module-actions/.eslintrc.js b/packages/cli-module-actions/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/packages/cli-module-actions/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/packages/cli-module-actions/bin/backstage-cli-module-actions b/packages/cli-module-actions/bin/backstage-cli-module-actions
new file mode 100755
index 0000000000..59f8267233
--- /dev/null
+++ b/packages/cli-module-actions/bin/backstage-cli-module-actions
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/*
+ * 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 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-actions/catalog-info.yaml b/packages/cli-module-actions/catalog-info.yaml
new file mode 100644
index 0000000000..fd282ca6a3
--- /dev/null
+++ b/packages/cli-module-actions/catalog-info.yaml
@@ -0,0 +1,10 @@
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: backstage-cli-module-actions
+ title: '@backstage/cli-module-actions'
+ description: CLI module for executing distributed actions
+spec:
+ lifecycle: experimental
+ type: backstage-cli-module
+ owner: tooling-maintainers
diff --git a/packages/cli-module-actions/cli-report.md b/packages/cli-module-actions/cli-report.md
new file mode 100644
index 0000000000..bbbab62b3f
--- /dev/null
+++ b/packages/cli-module-actions/cli-report.md
@@ -0,0 +1,94 @@
+## CLI Report file for "@backstage/cli-module-actions"
+
+> Do not edit this file. It is a report generated by `yarn build:api-reports`
+
+### `backstage-cli-module-actions`
+
+```
+Usage: @backstage/cli-module-actions [options] [command]
+
+Options:
+ -V, --version
+ -h, --help
+
+Commands:
+ actions [command]
+ help [command]
+```
+
+### `backstage-cli-module-actions actions`
+
+```
+Usage: @backstage/cli-module-actions actions [options] [command] [command]
+
+Options:
+ -h, --help
+
+Commands:
+ execute
+ help [command]
+ list
+ sources [command]
+```
+
+### `backstage-cli-module-actions actions execute`
+
+```
+Usage: @backstage/cli-module-actions actions execute
+
+Options:
+ --instance
+ -h, --help
+```
+
+### `backstage-cli-module-actions actions list`
+
+```
+Usage: @backstage/cli-module-actions actions list
+
+Options:
+ --instance
+ -h, --help
+```
+
+### `backstage-cli-module-actions actions sources`
+
+```
+Usage: @backstage/cli-module-actions actions sources [options] [command] [command]
+
+Options:
+ -h, --help
+
+Commands:
+ add
+ help [command]
+ list
+ remove
+```
+
+### `backstage-cli-module-actions actions sources add`
+
+```
+Usage: @backstage/cli-module-actions actions sources add
+
+Options:
+ -h, --help
+```
+
+### `backstage-cli-module-actions actions sources list`
+
+```
+Usage: @backstage/cli-module-actions actions sources list
+
+Options:
+ -h, --help
+```
+
+### `backstage-cli-module-actions actions sources remove`
+
+```
+Usage: @backstage/cli-module-actions actions sources remove
+
+Options:
+ -h, --help
+```
diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json
new file mode 100644
index 0000000000..fe19a8a90d
--- /dev/null
+++ b/packages/cli-module-actions/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@backstage/cli-module-actions",
+ "version": "0.0.0",
+ "description": "CLI module for executing distributed actions",
+ "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-actions"
+ },
+ "license": "Apache-2.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "files": [
+ "dist",
+ "bin"
+ ],
+ "bin": "bin/backstage-cli-module-actions",
+ "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-module-auth": "workspace:^",
+ "@backstage/cli-node": "workspace:^",
+ "cleye": "^2.3.0"
+ },
+ "devDependencies": {
+ "@backstage/backend-test-utils": "workspace:^",
+ "@backstage/cli": "workspace:^"
+ }
+}
diff --git a/packages/cli-module-actions/report.api.md b/packages/cli-module-actions/report.api.md
new file mode 100644
index 0000000000..180186482d
--- /dev/null
+++ b/packages/cli-module-actions/report.api.md
@@ -0,0 +1,13 @@
+## API Report File for "@backstage/cli-module-actions"
+
+> 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-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts
new file mode 100644
index 0000000000..4ff955096b
--- /dev/null
+++ b/packages/cli-module-actions/src/commands/execute.ts
@@ -0,0 +1,109 @@
+/*
+ * 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 { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+import { ActionsClient } from '../lib/ActionsClient';
+import { schemaToFlags } from '../lib/schemaToFlags';
+import { resolveAuth } from '../lib/resolveAuth';
+
+export default async ({ args, info }: CliCommandContext) => {
+ if (args.includes('--help') || args.includes('-h')) {
+ cli(
+ {
+ help: info,
+ parameters: [''],
+ flags: {
+ instance: {
+ type: String,
+ description: 'Name of the instance to use',
+ },
+ },
+ },
+ undefined,
+ args,
+ );
+ return;
+ }
+
+ const instanceIdx = args.indexOf('--instance');
+ const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined;
+
+ // Skip flag names, flag values (the argument after a known flag), and
+ // the --instance value position so we only pick up positional arguments.
+ const skipIndices = new Set();
+ if (instanceIdx !== -1) {
+ skipIndices.add(instanceIdx);
+ skipIndices.add(instanceIdx + 1);
+ }
+
+ let actionId: string | undefined;
+ let actionIdIdx = -1;
+ for (let i = 0; i < args.length; i++) {
+ if (!skipIndices.has(i) && !args[i].startsWith('-')) {
+ actionId = args[i];
+ actionIdIdx = i;
+ break;
+ }
+ }
+
+ if (!actionId) {
+ process.stderr.write('Usage: actions execute [flags]\n');
+ process.exit(1);
+ }
+
+ const { accessToken, instance } = await resolveAuth(instanceFlag);
+
+ const client = new ActionsClient(instance.baseUrl, accessToken);
+ const actions = await client.listForPlugin(actionId);
+ const action = actions.find(a => a.id === actionId);
+
+ if (!action) {
+ throw new Error(
+ `Action "${actionId}" not found. Run "actions list" to see available actions.`,
+ );
+ }
+
+ const schemaFlags = schemaToFlags(action.schema.input as any);
+
+ const flagArgs = args.filter((_, i) => i !== actionIdIdx);
+
+ const { flags } = cli(
+ {
+ help: info,
+ flags: {
+ instance: {
+ type: String,
+ description: 'Name of the instance to use',
+ },
+ ...schemaFlags,
+ },
+ },
+ undefined,
+ flagArgs,
+ );
+
+ const allFlags = flags as Record;
+ const input: Record = {};
+ for (const [key, value] of Object.entries(allFlags)) {
+ if (key !== 'instance' && value !== undefined) {
+ input[key] = value;
+ }
+ }
+
+ const output = await client.execute(actionId, input);
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
+};
diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts
new file mode 100644
index 0000000000..601fa6d2c7
--- /dev/null
+++ b/packages/cli-module-actions/src/commands/list.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+import { ActionsClient } from '../lib/ActionsClient';
+import { resolveAuth } from '../lib/resolveAuth';
+
+export default async ({ args, info }: CliCommandContext) => {
+ const {
+ flags: { instance: instanceFlag },
+ } = cli(
+ {
+ help: info,
+ flags: {
+ instance: {
+ type: String,
+ description: 'Name of the instance to use',
+ },
+ },
+ },
+ undefined,
+ args,
+ );
+
+ const { accessToken, pluginSources, instance } = await resolveAuth(
+ instanceFlag,
+ );
+
+ if (!pluginSources.length) {
+ process.stderr.write(
+ 'No plugin sources configured. Run "actions sources add " to add one.\n',
+ );
+ return;
+ }
+
+ const client = new ActionsClient(instance.baseUrl, accessToken);
+ const actions = await client.list(pluginSources);
+
+ if (!actions.length) {
+ process.stderr.write('No actions found.\n');
+ return;
+ }
+
+ for (const action of actions) {
+ const desc = action.description ? ` - ${action.description}` : '';
+ process.stdout.write(`${action.id}${desc}\n`);
+ }
+};
diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts
new file mode 100644
index 0000000000..2a8135b63f
--- /dev/null
+++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+import {
+ getSelectedInstance,
+ getInstanceConfig,
+ updateInstanceConfig,
+} from '@backstage/cli-module-auth';
+
+export default async ({ args, info }: CliCommandContext) => {
+ const parsed = cli(
+ {
+ help: info,
+ parameters: [''],
+ },
+ undefined,
+ args,
+ );
+
+ const pluginId = parsed._[0];
+
+ const instance = await getSelectedInstance();
+ const existing =
+ (await getInstanceConfig(instance.name, 'pluginSources')) ?? [];
+
+ if (existing.includes(pluginId)) {
+ process.stderr.write(
+ `Plugin source "${pluginId}" is already configured.\n`,
+ );
+ return;
+ }
+
+ await updateInstanceConfig(instance.name, 'pluginSources', [
+ ...existing,
+ pluginId,
+ ]);
+
+ process.stdout.write(`Added plugin source "${pluginId}".\n`);
+};
diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts
new file mode 100644
index 0000000000..68b368efd5
--- /dev/null
+++ b/packages/cli-module-actions/src/commands/sourcesList.ts
@@ -0,0 +1,39 @@
+/*
+ * 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 { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+import {
+ getSelectedInstance,
+ getInstanceConfig,
+} from '@backstage/cli-module-auth';
+
+export default async ({ args, info }: CliCommandContext) => {
+ cli({ help: info }, undefined, args);
+
+ const instance = await getSelectedInstance();
+ const sources =
+ (await getInstanceConfig(instance.name, 'pluginSources')) ?? [];
+
+ if (!sources.length) {
+ process.stderr.write('No plugin sources configured.\n');
+ return;
+ }
+
+ for (const source of sources) {
+ process.stdout.write(`${source}\n`);
+ }
+};
diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts
new file mode 100644
index 0000000000..731abe790d
--- /dev/null
+++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+import {
+ getSelectedInstance,
+ getInstanceConfig,
+ updateInstanceConfig,
+} from '@backstage/cli-module-auth';
+
+export default async ({ args, info }: CliCommandContext) => {
+ const parsed = cli(
+ {
+ help: info,
+ parameters: [''],
+ },
+ undefined,
+ args,
+ );
+
+ const pluginId = parsed._[0];
+
+ const instance = await getSelectedInstance();
+ const existing =
+ (await getInstanceConfig(instance.name, 'pluginSources')) ?? [];
+
+ if (!existing.includes(pluginId)) {
+ process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
+ return;
+ }
+
+ await updateInstanceConfig(
+ instance.name,
+ 'pluginSources',
+ existing.filter(s => s !== pluginId),
+ );
+
+ process.stdout.write(`Removed plugin source "${pluginId}".\n`);
+};
diff --git a/packages/cli-module-actions/src/index.ts b/packages/cli-module-actions/src/index.ts
new file mode 100644
index 0000000000..57b2d741e3
--- /dev/null
+++ b/packages/cli-module-actions/src/index.ts
@@ -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.
+ */
+
+import { createCliModule } from '@backstage/cli-node';
+import packageJson from '../package.json';
+
+export default createCliModule({
+ packageJson,
+ init: async reg => {
+ reg.addCommand({
+ path: ['actions', 'list'],
+ description: 'List available actions from configured plugin sources',
+ execute: { loader: () => import('./commands/list') },
+ });
+ reg.addCommand({
+ path: ['actions', 'execute'],
+ description: 'Execute an action',
+ execute: { loader: () => import('./commands/execute') },
+ });
+ reg.addCommand({
+ path: ['actions', 'sources', 'add'],
+ description: 'Add a plugin source for action discovery',
+ execute: { loader: () => import('./commands/sourcesAdd') },
+ });
+ reg.addCommand({
+ path: ['actions', 'sources', 'list'],
+ description: 'List configured plugin sources',
+ execute: { loader: () => import('./commands/sourcesList') },
+ });
+ reg.addCommand({
+ path: ['actions', 'sources', 'remove'],
+ description: 'Remove a plugin source',
+ execute: { loader: () => import('./commands/sourcesRemove') },
+ });
+ },
+});
diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts
new file mode 100644
index 0000000000..61526873c9
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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 { ActionsClient } from './ActionsClient';
+import { httpJson } from '@backstage/cli-module-auth';
+
+jest.mock('@backstage/cli-module-auth', () => ({
+ httpJson: jest.fn(),
+}));
+
+const mockHttpJson = httpJson as jest.MockedFunction;
+
+describe('ActionsClient', () => {
+ const baseUrl = 'https://backstage.example.com';
+ const accessToken = 'test-token';
+ let client: ActionsClient;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ client = new ActionsClient(baseUrl, accessToken);
+ });
+
+ describe('list', () => {
+ it('returns empty array when no plugin sources provided', async () => {
+ const result = await client.list([]);
+ expect(result).toEqual([]);
+ expect(mockHttpJson).not.toHaveBeenCalled();
+ });
+
+ it('fetches actions from each plugin source', async () => {
+ const catalogActions = [
+ {
+ id: 'catalog:refresh',
+ name: 'refresh',
+ schema: { input: {}, output: {} },
+ },
+ ];
+ const scaffolderActions = [
+ {
+ id: 'scaffolder:run',
+ name: 'run',
+ schema: { input: {}, output: {} },
+ },
+ ];
+
+ mockHttpJson
+ .mockResolvedValueOnce({ actions: catalogActions })
+ .mockResolvedValueOnce({ actions: scaffolderActions });
+
+ const result = await client.list(['catalog', 'scaffolder']);
+
+ expect(mockHttpJson).toHaveBeenCalledTimes(2);
+ expect(mockHttpJson).toHaveBeenCalledWith(
+ 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions',
+ expect.objectContaining({
+ headers: { Authorization: 'Bearer test-token' },
+ }),
+ );
+ expect(mockHttpJson).toHaveBeenCalledWith(
+ 'https://backstage.example.com/api/scaffolder/.backstage/actions/v1/actions',
+ expect.objectContaining({
+ headers: { Authorization: 'Bearer test-token' },
+ }),
+ );
+ expect(result).toEqual([...catalogActions, ...scaffolderActions]);
+ });
+
+ it('propagates errors from httpJson', async () => {
+ mockHttpJson.mockRejectedValue(new Error('Network error'));
+
+ await expect(client.list(['catalog'])).rejects.toThrow('Network error');
+ });
+ });
+
+ describe('execute', () => {
+ it('posts to the correct invoke endpoint', async () => {
+ mockHttpJson.mockResolvedValue({ output: { result: 'ok' } });
+
+ const output = await client.execute('catalog:refresh', {
+ entityRef: 'component:default/foo',
+ });
+
+ expect(mockHttpJson).toHaveBeenCalledWith(
+ 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions/catalog%3Arefresh/invoke',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { Authorization: 'Bearer test-token' },
+ body: { entityRef: 'component:default/foo' },
+ }),
+ );
+ expect(output).toEqual({ result: 'ok' });
+ });
+
+ it('sends empty object when no input provided', async () => {
+ mockHttpJson.mockResolvedValue({ output: null });
+
+ await client.execute('catalog:refresh');
+
+ expect(mockHttpJson).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ body: {} }),
+ );
+ });
+
+ it('extracts pluginId from actionId to build correct URL', async () => {
+ mockHttpJson.mockResolvedValue({ output: {} });
+
+ await client.execute('my-plugin:some-action');
+
+ expect(mockHttpJson).toHaveBeenCalledWith(
+ expect.stringContaining('/api/my-plugin/'),
+ expect.any(Object),
+ );
+ });
+ });
+});
diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts
new file mode 100644
index 0000000000..aa04902b2f
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/ActionsClient.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 { httpJson } from '@backstage/cli-module-auth';
+
+export type ActionDef = {
+ id: string;
+ name: string;
+ description?: string;
+ schema: {
+ input: object;
+ output: object;
+ };
+};
+
+type ListActionsResponse = {
+ actions: ActionDef[];
+};
+
+type InvokeResponse = {
+ output: unknown;
+};
+
+function extractPluginId(actionId: string): string {
+ const colonIndex = actionId.indexOf(':');
+ if (colonIndex === -1) {
+ throw new Error(
+ `Invalid action ID "${actionId}". Expected format "pluginId:actionName".`,
+ );
+ }
+ return actionId.substring(0, colonIndex);
+}
+
+function pluginActionsUrl(baseUrl: string, pluginId: string): string {
+ return new URL(
+ `/api/${encodeURIComponent(pluginId)}/.backstage/actions/v1/actions`,
+ baseUrl,
+ ).toString();
+}
+
+export class ActionsClient {
+ constructor(
+ private readonly baseUrl: string,
+ private readonly accessToken: string,
+ ) {}
+
+ async list(pluginSources: string[]): Promise {
+ const results: ActionDef[] = [];
+
+ for (const pluginId of pluginSources) {
+ const url = pluginActionsUrl(this.baseUrl, pluginId);
+
+ const response = await httpJson(url, {
+ headers: { Authorization: `Bearer ${this.accessToken}` },
+ signal: AbortSignal.timeout(30_000),
+ });
+
+ results.push(...response.actions);
+ }
+
+ return results;
+ }
+
+ async listForPlugin(actionId: string): Promise {
+ const pluginId = extractPluginId(actionId);
+ return this.list([pluginId]);
+ }
+
+ async execute(actionId: string, input?: unknown): Promise {
+ const pluginId = extractPluginId(actionId);
+ const url = `${pluginActionsUrl(
+ this.baseUrl,
+ pluginId,
+ )}/${encodeURIComponent(actionId)}/invoke`;
+
+ const response = await httpJson(url, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${this.accessToken}` },
+ body: input ?? {},
+ signal: AbortSignal.timeout(30_000),
+ });
+
+ return response.output;
+ }
+}
diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts
new file mode 100644
index 0000000000..f2702cf757
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts
@@ -0,0 +1,124 @@
+/*
+ * 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 { resolveAuth } from './resolveAuth';
+import {
+ getSelectedInstance,
+ getInstanceConfig,
+ accessTokenNeedsRefresh,
+ refreshAccessToken,
+ getSecretStore,
+ type StoredInstance,
+} from '@backstage/cli-module-auth';
+
+jest.mock('@backstage/cli-module-auth', () => ({
+ getSelectedInstance: jest.fn(),
+ getInstanceConfig: jest.fn(),
+ accessTokenNeedsRefresh: jest.fn(),
+ refreshAccessToken: jest.fn(),
+ getSecretStore: jest.fn(),
+}));
+
+const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction<
+ typeof getSelectedInstance
+>;
+const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction<
+ typeof getInstanceConfig
+>;
+const mockAccessTokenNeedsRefresh =
+ accessTokenNeedsRefresh as jest.MockedFunction<
+ typeof accessTokenNeedsRefresh
+ >;
+const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction<
+ typeof refreshAccessToken
+>;
+const mockGetSecretStore = getSecretStore as jest.MockedFunction<
+ typeof getSecretStore
+>;
+
+describe('resolveAuth', () => {
+ const mockInstance: StoredInstance = {
+ name: 'production',
+ baseUrl: 'https://backstage.example.com',
+ clientId: 'my-client',
+ issuedAt: Date.now(),
+ accessTokenExpiresAt: Date.now() + 3600_000,
+ };
+
+ const mockSecretStore = {
+ get: jest.fn(),
+ set: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetSelectedInstance.mockResolvedValue(mockInstance);
+ mockAccessTokenNeedsRefresh.mockReturnValue(false);
+ mockGetSecretStore.mockResolvedValue(mockSecretStore);
+ mockSecretStore.get.mockResolvedValue('test-access-token');
+ mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']);
+ });
+
+ it('resolves auth with the selected instance and stored token', async () => {
+ const result = await resolveAuth();
+
+ expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined);
+ expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance);
+ expect(mockRefreshAccessToken).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ instance: mockInstance,
+ accessToken: 'test-access-token',
+ pluginSources: ['catalog', 'scaffolder'],
+ });
+ });
+
+ it('passes instance name flag to getSelectedInstance', async () => {
+ await resolveAuth('staging');
+
+ expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging');
+ });
+
+ it('refreshes the access token when it is about to expire', async () => {
+ const refreshedInstance = {
+ ...mockInstance,
+ accessTokenExpiresAt: Date.now() + 7200_000,
+ };
+ mockAccessTokenNeedsRefresh.mockReturnValue(true);
+ mockRefreshAccessToken.mockResolvedValue(refreshedInstance);
+
+ const result = await resolveAuth();
+
+ expect(mockRefreshAccessToken).toHaveBeenCalledWith('production');
+ expect(result.instance).toBe(refreshedInstance);
+ });
+
+ it('throws when no access token is stored', async () => {
+ mockSecretStore.get.mockResolvedValue(undefined);
+
+ await expect(resolveAuth()).rejects.toThrow(
+ 'No access token found. Run "auth login" to authenticate.',
+ );
+ });
+
+ it('returns empty plugin sources when none are configured', async () => {
+ mockGetInstanceConfig.mockResolvedValue(undefined);
+
+ const result = await resolveAuth();
+
+ expect(result.pluginSources).toEqual([]);
+ });
+});
diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts
new file mode 100644
index 0000000000..11fb646545
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/resolveAuth.ts
@@ -0,0 +1,48 @@
+/*
+ * 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 {
+ getSelectedInstance,
+ getInstanceConfig,
+ accessTokenNeedsRefresh,
+ refreshAccessToken,
+ getSecretStore,
+ type StoredInstance,
+} from '@backstage/cli-module-auth';
+
+export async function resolveAuth(instanceFlag?: string): Promise<{
+ instance: StoredInstance;
+ accessToken: string;
+ pluginSources: string[];
+}> {
+ let instance = await getSelectedInstance(instanceFlag);
+
+ if (accessTokenNeedsRefresh(instance)) {
+ instance = await refreshAccessToken(instance.name);
+ }
+
+ const secretStore = await getSecretStore();
+ const service = `backstage-cli:auth-instance:${instance.name}`;
+ const accessToken = await secretStore.get(service, 'accessToken');
+ if (!accessToken) {
+ throw new Error('No access token found. Run "auth login" to authenticate.');
+ }
+
+ const pluginSources =
+ (await getInstanceConfig(instance.name, 'pluginSources')) ?? [];
+
+ return { instance, accessToken, pluginSources };
+}
diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts
new file mode 100644
index 0000000000..fa9f446aae
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts
@@ -0,0 +1,163 @@
+/*
+ * 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 { schemaToFlags } from './schemaToFlags';
+
+describe('schemaToFlags', () => {
+ it('returns empty object when schema has no properties', () => {
+ expect(schemaToFlags({})).toEqual({});
+ expect(schemaToFlags({ properties: {} })).toEqual({});
+ });
+
+ it('converts string properties to String flags', () => {
+ const flags = schemaToFlags({
+ properties: {
+ myProp: { type: 'string', description: 'A string prop' },
+ },
+ });
+
+ expect(flags).toEqual({
+ myProp: { type: String, description: 'A string prop' },
+ });
+ });
+
+ it('converts number and integer properties to Number flags', () => {
+ const flags = schemaToFlags({
+ properties: {
+ count: { type: 'integer' },
+ amount: { type: 'number', description: 'An amount' },
+ },
+ });
+
+ expect(flags.count).toEqual({ type: Number, description: undefined });
+ expect(flags.amount).toEqual({ type: Number, description: 'An amount' });
+ });
+
+ it('converts boolean properties to Boolean flags', () => {
+ const flags = schemaToFlags({
+ properties: {
+ verbose: { type: 'boolean', description: 'Enable verbose output' },
+ },
+ });
+
+ expect(flags.verbose).toEqual({
+ type: Boolean,
+ description: 'Enable verbose output',
+ });
+ });
+
+ it('skips non-primitive properties like object and array', () => {
+ const flags = schemaToFlags({
+ properties: {
+ name: { type: 'string' },
+ metadata: { type: 'object' },
+ tags: { type: 'array' },
+ },
+ });
+
+ expect(Object.keys(flags)).toEqual(['name']);
+ });
+
+ it('skips properties with no type or composite types', () => {
+ const flags = schemaToFlags({
+ properties: {
+ noType: {},
+ name: { type: 'string' },
+ },
+ });
+
+ expect(Object.keys(flags)).toEqual(['name']);
+ });
+
+ it('uses first type when type is an array', () => {
+ const flags = schemaToFlags({
+ properties: {
+ value: { type: ['string', 'null'] },
+ },
+ });
+
+ expect(flags.value).toEqual({ type: String, description: undefined });
+ });
+
+ it('appends enum values to description', () => {
+ const flags = schemaToFlags({
+ properties: {
+ color: {
+ type: 'string',
+ description: 'Pick a color',
+ enum: ['red', 'green', 'blue'],
+ },
+ bare: { type: 'string', enum: ['a', 'b'] },
+ },
+ });
+
+ expect(flags.color.description).toBe('Pick a color [red, green, blue]');
+ expect(flags.bare.description).toBe('[a, b]');
+ });
+
+ it('marks required fields in description', () => {
+ const flags = schemaToFlags({
+ properties: {
+ name: { type: 'string', description: 'The name' },
+ optional: { type: 'string', description: 'Optional field' },
+ bare: { type: 'string' },
+ },
+ required: ['name', 'bare'],
+ });
+
+ expect(flags.name.description).toBe('The name (required)');
+ expect(flags.optional.description).toBe('Optional field');
+ expect(flags.bare.description).toBe('(required)');
+ });
+
+ it('applies default values from schema', () => {
+ const flags = schemaToFlags({
+ properties: {
+ count: { type: 'number', default: 10 },
+ name: { type: 'string' },
+ },
+ });
+
+ expect(flags.count.default).toBe(10);
+ expect(flags.name.default).toBeUndefined();
+ });
+
+ it('combines enum and required in description', () => {
+ const flags = schemaToFlags({
+ properties: {
+ env: {
+ type: 'string',
+ description: 'Target env',
+ enum: ['dev', 'prod'],
+ },
+ },
+ required: ['env'],
+ });
+
+ expect(flags.env.description).toBe('Target env [dev, prod] (required)');
+ });
+
+ it('preserves camelCase property names as flag keys', () => {
+ const flags = schemaToFlags({
+ properties: {
+ targetEntityRef: { type: 'string' },
+ maxResults: { type: 'integer' },
+ },
+ });
+
+ expect(Object.keys(flags)).toEqual(['targetEntityRef', 'maxResults']);
+ });
+});
diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.ts b/packages/cli-module-actions/src/lib/schemaToFlags.ts
new file mode 100644
index 0000000000..026613edaf
--- /dev/null
+++ b/packages/cli-module-actions/src/lib/schemaToFlags.ts
@@ -0,0 +1,77 @@
+/*
+ * 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.
+ */
+
+type JsonSchemaProperty = {
+ type?: string | string[];
+ description?: string;
+ enum?: unknown[];
+ default?: unknown;
+};
+
+type JsonSchemaObject = {
+ properties?: Record;
+ required?: string[];
+};
+
+type CleyeFlag = {
+ type: StringConstructor | NumberConstructor | BooleanConstructor;
+ description?: string;
+ default?: unknown;
+};
+
+export function schemaToFlags(
+ schema: JsonSchemaObject,
+): Record {
+ const flags: Record = {};
+ const required = new Set(schema.required ?? []);
+
+ if (!schema.properties) {
+ return flags;
+ }
+
+ for (const [key, prop] of Object.entries(schema.properties)) {
+ const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type;
+
+ let flagType: StringConstructor | NumberConstructor | BooleanConstructor;
+ if (rawType === 'string') {
+ flagType = String;
+ } else if (rawType === 'number' || rawType === 'integer') {
+ flagType = Number;
+ } else if (rawType === 'boolean') {
+ flagType = Boolean;
+ } else {
+ continue;
+ }
+
+ let desc = prop.description ?? '';
+ if (prop.enum?.length) {
+ const values = prop.enum.map(v => String(v)).join(', ');
+ desc = desc ? `${desc} [${values}]` : `[${values}]`;
+ }
+ if (required.has(key)) {
+ desc = desc ? `${desc} (required)` : '(required)';
+ }
+
+ const flag: CleyeFlag = { type: flagType, description: desc || undefined };
+ if (prop.default !== undefined) {
+ flag.default = prop.default;
+ }
+
+ flags[key] = flag;
+ }
+
+ return flags;
+}
diff --git a/packages/cli-module-auth/package.json b/packages/cli-module-auth/package.json
index 86bb491f7a..94169e998e 100644
--- a/packages/cli-module-auth/package.json
+++ b/packages/cli-module-auth/package.json
@@ -35,7 +35,6 @@
"@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",
diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md
index 510bcaabe7..a3b98e2f02 100644
--- a/packages/cli-module-auth/report.api.md
+++ b/packages/cli-module-auth/report.api.md
@@ -5,9 +5,67 @@
```ts
import { CliModule } from '@backstage/cli-node';
+// @public (undocumented)
+export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
+
// @public (undocumented)
const _default: CliModule;
export default _default;
+// @public (undocumented)
+export function getInstanceConfig(
+ instanceName: string,
+ key: string,
+): Promise;
+
+// @public (undocumented)
+export function getSecretStore(): Promise;
+
+// @public (undocumented)
+export function getSelectedInstance(
+ instanceName?: string,
+): Promise;
+
+// @public (undocumented)
+export type HttpInit = {
+ headers?: Record;
+ method?: string;
+ body?: any;
+ signal?: AbortSignal;
+};
+
+// @public (undocumented)
+export function httpJson(url: string, init?: HttpInit): Promise;
+
+// @public (undocumented)
+export function refreshAccessToken(
+ instanceName: string,
+): Promise;
+
+// @public (undocumented)
+export type SecretStore = {
+ get(service: string, account: string): Promise;
+ set(service: string, account: string, secret: string): Promise;
+ delete(service: string, account: string): Promise;
+};
+
+// @public (undocumented)
+export type StoredInstance = {
+ name: string;
+ baseUrl: string;
+ clientId: string;
+ issuedAt: number;
+ accessTokenExpiresAt: number;
+ selected?: boolean;
+ config?: Record;
+};
+
+// @public (undocumented)
+export function updateInstanceConfig(
+ instanceName: string,
+ key: string,
+ value: unknown,
+): Promise;
+
// (No @packageDocumentation comment for this package)
```
diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts
index fef74ec8a5..38567e2174 100644
--- a/packages/cli-module-auth/src/index.ts
+++ b/packages/cli-module-auth/src/index.ts
@@ -52,3 +52,17 @@ export default createCliModule({
});
},
});
+
+/** @public */
+export {
+ getSelectedInstance,
+ getInstanceConfig,
+ updateInstanceConfig,
+ type StoredInstance,
+} from './lib/storage';
+/** @public */
+export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
+/** @public */
+export { getSecretStore, type SecretStore } from './lib/secretStore';
+/** @public */
+export { httpJson, type HttpInit } from './lib/http';
diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts
index 3ea7baef5c..0344d4636e 100644
--- a/packages/cli-module-auth/src/lib/auth.ts
+++ b/packages/cli-module-auth/src/lib/auth.ts
@@ -31,10 +31,12 @@ const TokenResponseSchema = z.object({
refresh_token: z.string().min(1).optional(),
});
+/** @public */
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
}
+/** @public */
export async function refreshAccessToken(
instanceName: string,
): Promise {
diff --git a/packages/cli-module-auth/src/lib/http.test.ts b/packages/cli-module-auth/src/lib/http.test.ts
index 74416a0dee..40ab7009b1 100644
--- a/packages/cli-module-auth/src/lib/http.test.ts
+++ b/packages/cli-module-auth/src/lib/http.test.ts
@@ -14,12 +14,13 @@
* limitations under the License.
*/
-import fetch from 'cross-fetch';
import { httpJson } from './http';
-jest.mock('cross-fetch');
+const mockFetch = jest.fn() as jest.MockedFunction;
-const mockFetch = fetch as jest.MockedFunction;
+beforeEach(() => {
+ global.fetch = mockFetch;
+});
describe('http', () => {
beforeEach(() => {
diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts
index 307d7d2334..4861a07722 100644
--- a/packages/cli-module-auth/src/lib/http.ts
+++ b/packages/cli-module-auth/src/lib/http.ts
@@ -14,16 +14,17 @@
* limitations under the License.
*/
-import fetch from 'cross-fetch';
import { ResponseError } from '@backstage/errors';
-type HttpInit = {
+/** @public */
+export type HttpInit = {
headers?: Record;
method?: string;
body?: any;
signal?: AbortSignal;
};
+/** @public */
export async function httpJson(url: string, init?: HttpInit): Promise {
const res = await fetch(url, {
...init,
diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts
index 55fac878b7..a0f3c81d51 100644
--- a/packages/cli-module-auth/src/lib/secretStore.ts
+++ b/packages/cli-module-auth/src/lib/secretStore.ts
@@ -18,7 +18,8 @@ import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
-type SecretStore = {
+/** @public */
+export type SecretStore = {
get(service: string, account: string): Promise;
set(service: string, account: string, secret: string): Promise;
delete(service: string, account: string): Promise;
@@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore {
let singleton: SecretStore | undefined;
+/** @public */
export async function getSecretStore(): Promise {
if (!singleton) {
const keytar = await loadKeytar();
diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts
index 07ffba9510..9264c6bb89 100644
--- a/packages/cli-module-auth/src/lib/storage.test.ts
+++ b/packages/cli-module-auth/src/lib/storage.test.ts
@@ -22,6 +22,8 @@ import {
getAllInstances,
getSelectedInstance,
getInstanceByName,
+ getInstanceConfig,
+ updateInstanceConfig,
upsertInstance,
removeInstance,
setSelectedInstance,
@@ -357,6 +359,69 @@ describe('storage', () => {
});
});
+ describe('getInstanceConfig', () => {
+ it('should return undefined when no config set', async () => {
+ await upsertInstance(mockInstance1);
+
+ const result = await getInstanceConfig('production', 'someKey');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return config value for a key', async () => {
+ await upsertInstance(mockInstance1);
+ await updateInstanceConfig('production', 'myKey', 'myValue');
+
+ const result = await getInstanceConfig('production', 'myKey');
+ expect(result).toBe('myValue');
+ });
+
+ it('should throw NotFoundError for unknown instance', async () => {
+ await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow(
+ NotFoundError,
+ );
+ });
+ });
+
+ describe('updateInstanceConfig', () => {
+ it('should set a config value', async () => {
+ await upsertInstance(mockInstance1);
+ await updateInstanceConfig('production', 'key1', 'value1');
+
+ const result = await getInstanceConfig('production', 'key1');
+ expect(result).toBe('value1');
+ });
+
+ it('should preserve existing config keys', async () => {
+ await upsertInstance(mockInstance1);
+ await updateInstanceConfig('production', 'key1', 'value1');
+ await updateInstanceConfig('production', 'key2', 'value2');
+
+ const result1 = await getInstanceConfig('production', 'key1');
+ const result2 = await getInstanceConfig('production', 'key2');
+ expect(result1).toBe('value1');
+ expect(result2).toBe('value2');
+ });
+
+ it('should throw NotFoundError for unknown instance', async () => {
+ await expect(
+ updateInstanceConfig('nonexistent', 'key', 'value'),
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it('should remove instance along with its config', async () => {
+ await upsertInstance(mockInstance1);
+ await updateInstanceConfig('production', 'key1', 'value1');
+ await removeInstance('production');
+
+ const { instances } = await getAllInstances();
+ expect(instances.find(i => i.name === 'production')).toBeUndefined();
+
+ await upsertInstance(mockInstance1);
+ const result = await getInstanceConfig('production', 'key1');
+ expect(result).toBeUndefined();
+ });
+ });
+
describe('file path resolution', () => {
it('should use XDG_CONFIG_HOME when set', async () => {
const customConfigHome = mockDir.resolve('custom-config');
diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts
index c2eb153112..9d2551a4f9 100644
--- a/packages/cli-module-auth/src/lib/storage.ts
+++ b/packages/cli-module-auth/src/lib/storage.ts
@@ -36,9 +36,19 @@ const storedInstanceSchema = z.object({
issuedAt: z.number().int().nonnegative(),
accessTokenExpiresAt: z.number().int().nonnegative(),
selected: z.boolean().optional(),
+ config: z.record(z.string(), z.unknown()).optional(),
});
-export type StoredInstance = z.infer;
+/** @public */
+export type StoredInstance = {
+ name: string;
+ baseUrl: string;
+ clientId: string;
+ issuedAt: number;
+ accessTokenExpiresAt: number;
+ selected?: boolean;
+ config?: Record;
+};
const authYamlSchema = z.object({
instances: z.array(storedInstanceSchema).default([]),
@@ -98,6 +108,7 @@ export async function getAllInstances(): Promise<{
};
}
+/** @public */
export async function getSelectedInstance(
instanceName?: string,
): Promise {
@@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise {
});
}
+/** @public */
+export async function getInstanceConfig(
+ instanceName: string,
+ key: string,
+): Promise {
+ const instance = await getInstanceByName(instanceName);
+ return instance.config?.[key] as T | undefined;
+}
+
+/** @public */
+export async function updateInstanceConfig(
+ instanceName: string,
+ key: string,
+ value: unknown,
+): Promise {
+ return withMetadataLock(async () => {
+ const data = await readAll();
+ const idx = data.instances.findIndex(i => i.name === instanceName);
+ if (idx === -1) {
+ throw new NotFoundError(`Instance '${instanceName}' not found`);
+ }
+ data.instances[idx] = {
+ ...data.instances[idx],
+ config: { ...data.instances[idx].config, [key]: value },
+ };
+ await writeAll(data);
+ });
+}
+
export async function withMetadataLock(fn: () => Promise): Promise {
const file = getMetadataFilePath();
await fs.ensureDir(path.dirname(file));
diff --git a/packages/cli-module-new/src/lib/defaultTemplates.ts b/packages/cli-module-new/src/lib/defaultTemplates.ts
index 9d1543c452..dea35a6e99 100644
--- a/packages/cli-module-new/src/lib/defaultTemplates.ts
+++ b/packages/cli-module-new/src/lib/defaultTemplates.ts
@@ -23,6 +23,7 @@ export const defaultTemplates = [
'@backstage/cli/templates/plugin-common-library',
'@backstage/cli/templates/web-library',
'@backstage/cli/templates/node-library',
+ '@backstage/cli/templates/cli-module',
'@backstage/cli/templates/catalog-provider-module',
'@backstage/cli/templates/scaffolder-backend-module',
];
diff --git a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts
index 7279801d62..63e3791a49 100644
--- a/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts
+++ b/packages/cli-module-new/src/lib/preparation/collectPortableTemplateInput.ts
@@ -160,6 +160,7 @@ export function getPromptsForRole(
case 'web-library':
case 'node-library':
case 'common-library':
+ case 'cli-module':
return [namePrompt()];
case 'plugin-web-library':
case 'plugin-node-library':
diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts
index df128d4755..f279b702b1 100644
--- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts
+++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.test.ts
@@ -37,6 +37,13 @@ describe.each([
packagePath: 'packages/test',
},
],
+ [
+ { role: 'cli-module', name: 'test' },
+ {
+ packageName: '@internal/cli-module-test',
+ packagePath: 'packages/cli-module-test',
+ },
+ ],
[
{ role: 'plugin-web-library', pluginId: 'test' },
{
diff --git a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts
index dd85f49fad..8a4f87c5c6 100644
--- a/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts
+++ b/packages/cli-module-new/src/lib/preparation/resolvePackageParams.ts
@@ -48,6 +48,8 @@ function getBaseNameForRole(
case 'node-library':
case 'common-library':
return roleParams.name;
+ case 'cli-module':
+ return `cli-module-${roleParams.name}`;
case 'plugin-web-library':
return `${roleParams.pluginId}-react`;
case 'plugin-node-library':
diff --git a/packages/cli-module-new/src/lib/types.ts b/packages/cli-module-new/src/lib/types.ts
index 9f221ea7aa..83a195a43a 100644
--- a/packages/cli-module-new/src/lib/types.ts
+++ b/packages/cli-module-new/src/lib/types.ts
@@ -50,6 +50,7 @@ export const TEMPLATE_ROLES = [
'web-library',
'node-library',
'common-library',
+ 'cli-module',
'plugin-web-library',
'plugin-node-library',
'plugin-common-library',
@@ -80,7 +81,7 @@ export type PortableTemplateParams = {
export type PortableTemplateInputRoleParams =
| {
- role: 'web-library' | 'node-library' | 'common-library';
+ role: 'web-library' | 'node-library' | 'common-library' | 'cli-module';
name: string;
}
| {
diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md
index bb22326786..4408c6e969 100644
--- a/packages/cli/cli-report.md
+++ b/packages/cli/cli-report.md
@@ -12,6 +12,7 @@ Options:
-h, --help
Commands:
+ actions [command]
auth [command]
build-workspace
config [command]
@@ -31,6 +32,83 @@ Commands:
versions:migrate
```
+### `backstage-cli actions`
+
+```
+Usage: backstage-cli actions [options] [command] [command]
+
+Options:
+ -h, --help
+
+Commands:
+ execute
+ help [command]
+ list
+ sources [command]
+```
+
+### `backstage-cli actions execute`
+
+```
+Usage: backstage-cli actions execute
+
+Options:
+ --instance
+ -h, --help
+```
+
+### `backstage-cli actions list`
+
+```
+Usage: backstage-cli actions list
+
+Options:
+ --instance
+ -h, --help
+```
+
+### `backstage-cli actions sources`
+
+```
+Usage: backstage-cli actions sources [options] [command] [command]
+
+Options:
+ -h, --help
+
+Commands:
+ add
+ help [command]
+ list
+ remove
+```
+
+### `backstage-cli actions sources add`
+
+```
+Usage: backstage-cli actions sources add
+
+Options:
+ -h, --help
+```
+
+### `backstage-cli actions sources list`
+
+```
+Usage: backstage-cli actions sources list
+
+Options:
+ -h, --help
+```
+
+### `backstage-cli actions sources remove`
+
+```
+Usage: backstage-cli actions sources remove
+
+Options:
+ -h, --help
+```
+
### `backstage-cli auth`
```
diff --git a/packages/cli/templates/cli-module/.eslintrc.js.hbs b/packages/cli/templates/cli-module/.eslintrc.js.hbs
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/packages/cli/templates/cli-module/.eslintrc.js.hbs
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/packages/cli/templates/cli-module/README.md.hbs b/packages/cli/templates/cli-module/README.md.hbs
new file mode 100644
index 0000000000..657b45805b
--- /dev/null
+++ b/packages/cli/templates/cli-module/README.md.hbs
@@ -0,0 +1,5 @@
+# {{packageName}}
+
+A CLI module that adds commands to the Backstage CLI.
+
+_This package was created through the Backstage CLI_
diff --git a/packages/cli/templates/cli-module/bin/{{binName}} b/packages/cli/templates/cli-module/bin/{{binName}}
new file mode 100644
index 0000000000..59f8267233
--- /dev/null
+++ b/packages/cli/templates/cli-module/bin/{{binName}}
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/*
+ * 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 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/templates/cli-module/package.json.hbs b/packages/cli/templates/cli-module/package.json.hbs
new file mode 100644
index 0000000000..5e077cca83
--- /dev/null
+++ b/packages/cli/templates/cli-module/package.json.hbs
@@ -0,0 +1,35 @@
+{
+ "name": "{{packageName}}",
+ "description": "CLI module for Backstage CLI",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.cjs.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "cli-module"
+ },
+ "scripts": {
+ "build": "backstage-cli package build",
+ "lint": "backstage-cli package lint",
+ "test": "backstage-cli package test",
+ "clean": "backstage-cli package clean",
+ "prepack": "backstage-cli package prepack",
+ "postpack": "backstage-cli package postpack"
+ },
+ "dependencies": {
+ "@backstage/cli-common": "{{versionQuery '@backstage/cli-common'}}",
+ "@backstage/cli-node": "{{versionQuery '@backstage/cli-node'}}",
+ "cleye": "^2.3.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "{{versionQuery '@backstage/cli'}}"
+ },
+ "files": [
+ "dist",
+ "bin"
+ ],
+ "bin": "bin/{{binName}}"
+}
diff --git a/packages/cli/templates/cli-module/portable-template.yaml b/packages/cli/templates/cli-module/portable-template.yaml
new file mode 100644
index 0000000000..72b6c2786b
--- /dev/null
+++ b/packages/cli/templates/cli-module/portable-template.yaml
@@ -0,0 +1,5 @@
+name: cli-module
+role: cli-module
+description: A CLI module that adds commands to the Backstage CLI
+values:
+ binName: 'backstage-cli-module-{{ name }}'
diff --git a/packages/cli/templates/cli-module/src/commands/example.ts b/packages/cli/templates/cli-module/src/commands/example.ts
new file mode 100644
index 0000000000..66722307b0
--- /dev/null
+++ b/packages/cli/templates/cli-module/src/commands/example.ts
@@ -0,0 +1,22 @@
+import { cli } from 'cleye';
+import type { CliCommandContext } from '@backstage/cli-node';
+
+export default async ({ args, info }: CliCommandContext) => {
+ const { flags } = cli(
+ {
+ help: info,
+ booleanFlagNegation: true,
+ flags: {
+ name: {
+ type: String,
+ description: 'Your name',
+ },
+ },
+ },
+ undefined,
+ args,
+ );
+
+ const name = flags.name ?? 'World';
+ console.log(`Hello, ${name}!`);
+};
diff --git a/packages/cli/templates/cli-module/src/index.ts.hbs b/packages/cli/templates/cli-module/src/index.ts.hbs
new file mode 100644
index 0000000000..a947d3d666
--- /dev/null
+++ b/packages/cli/templates/cli-module/src/index.ts.hbs
@@ -0,0 +1,20 @@
+/***/
+/**
+ * CLI module for the Backstage CLI.
+ *
+ * @packageDocumentation
+ */
+
+import { createCliModule } from '@backstage/cli-node';
+import packageJson from '../package.json';
+
+export default createCliModule({
+ packageJson,
+ init: async reg => {
+ reg.addCommand({
+ path: ['example'],
+ description: 'An example command',
+ execute: { loader: () => import('./commands/example') },
+ });
+ },
+});
diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json
index 152b6d1141..6ee93be875 100644
--- a/packages/core-compat-api/package.json
+++ b/packages/core-compat-api/package.json
@@ -33,6 +33,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
index f8436e7434..9a18d86b1f 100644
--- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
+++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
@@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => {
"getExtension": [Function],
"icon": undefined,
"id": "test",
+ "if": undefined,
"info": [Function],
"infoOptions": undefined,
"pluginId": "test",
diff --git a/packages/core-components/src/components/Progress/Progress.test.tsx b/packages/core-components/src/components/Progress/Progress.test.tsx
index 918c9ba518..f72454754e 100644
--- a/packages/core-components/src/components/Progress/Progress.test.tsx
+++ b/packages/core-components/src/components/Progress/Progress.test.tsx
@@ -15,6 +15,7 @@
*/
import { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
import { Progress } from './Progress';
@@ -23,4 +24,11 @@ describe('', () => {
const { queryByTestId } = await renderInTestApp();
expect(queryByTestId('progress')).toBeInTheDocument();
});
+
+ it('provides an accessible name for the progress bar', async () => {
+ await renderInTestApp();
+ expect(
+ await screen.findByRole('progressbar', { name: 'Loading' }),
+ ).toBeInTheDocument();
+ });
});
diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx
index b7176a5825..f1dea0ed5c 100644
--- a/packages/core-components/src/components/Progress/Progress.tsx
+++ b/packages/core-components/src/components/Progress/Progress.tsx
@@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles';
import { PropsWithChildren, useEffect, useState } from 'react';
export function Progress(props: PropsWithChildren) {
+ const { 'aria-label': ariaLabel, ...progressProps } = props;
const theme = useTheme();
const [isVisible, setIsVisible] = useState(false);
@@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren) {
}, [theme.transitions.duration.short]);
return isVisible ? (
-
+
) : (
);
diff --git a/packages/core-plugin-api/report.api.md b/packages/core-plugin-api/report.api.md
index 654bbe94d1..7165cfc697 100644
--- a/packages/core-plugin-api/report.api.md
+++ b/packages/core-plugin-api/report.api.md
@@ -28,7 +28,6 @@ import { ComponentType } from 'react';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { configApiRef } from '@backstage/frontend-plugin-api';
import { createApiFactory } from '@backstage/frontend-plugin-api';
-import { createApiRef } from '@backstage/frontend-plugin-api';
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
import { discoveryApiRef } from '@backstage/frontend-plugin-api';
import { ErrorApi } from '@backstage/frontend-plugin-api';
@@ -256,7 +255,8 @@ export { configApiRef };
export { createApiFactory };
-export { createApiRef };
+// @public
+export function createApiRef(config: ApiRefConfig): ApiRef;
// @public
export function createComponentExtension<
diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts
index dab872236f..5bfb83ce3e 100644
--- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts
+++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts
@@ -19,9 +19,15 @@ import { createApiRef } from './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
const ref = createApiRef({ id: 'abc' });
+ expect(ref.$$type).toBe('@backstage/ApiRef');
expect(ref.id).toBe('abc');
expect(String(ref)).toBe('apiRef{abc}');
- expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
+ expect(ref.T).toBeNull();
+ });
+
+ it('should not accept pluginId in the core createApiRef config', () => {
+ // @ts-expect-error pluginId is not supported in core-plugin-api
+ createApiRef({ id: 'abc', pluginId: 'test' });
});
it('should reject invalid ids', () => {
diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts
index ffd074b9a7..8c3986c583 100644
--- a/packages/core-plugin-api/src/apis/system/ApiRef.ts
+++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts
@@ -14,5 +14,23 @@
* limitations under the License.
*/
-export { createApiRef } from '@backstage/frontend-plugin-api';
-export type { ApiRefConfig } from '@backstage/frontend-plugin-api';
+import {
+ createApiRef as createFrontendApiRef,
+ type ApiRef,
+ type ApiRefConfig,
+} from '@backstage/frontend-plugin-api';
+
+const createFrontendApiRefCompat = createFrontendApiRef as (
+ config: ApiRefConfig,
+) => ApiRef;
+
+/**
+ * Creates a reference to an API.
+ *
+ * @public
+ */
+export function createApiRef(config: ApiRefConfig): ApiRef {
+ return createFrontendApiRefCompat(config);
+}
+
+export type { ApiRefConfig };
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index 1951c18828..f5dea017a9 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -36,6 +36,7 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
+ "@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
@@ -47,6 +48,7 @@
"@backstage/cli": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
+ "@backstage/plugin-permission-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md
index bf69cc83e6..7795794051 100644
--- a/packages/frontend-app-api/report.api.md
+++ b/packages/frontend-app-api/report.api.md
@@ -3,14 +3,14 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
-import { ApiHolder } from '@backstage/core-plugin-api';
-import { ApiHolder as ApiHolder_2 } from '@backstage/frontend-plugin-api';
+import { ApiHolder } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppTree } from '@backstage/frontend-plugin-api';
-import { ConfigApi } from '@backstage/core-plugin-api';
+import { ConfigApi } from '@backstage/frontend-plugin-api';
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
+import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -128,6 +128,26 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
+ EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
+ context: {
+ node: AppNode;
+ };
+ };
+ EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ };
+ };
+ EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ bootstrapNode: AppNode;
+ pluginId: string;
+ bootstrapPluginId: string;
+ };
+ };
ROUTE_DUPLICATE: {
context: {
routeId: string;
@@ -145,6 +165,12 @@ export type AppErrorTypes = {
};
};
+// @public
+export type BootstrapSpecializedApp = {
+ element: JSX.Element;
+ tree: AppTree;
+};
+
// @public
export type CreateAppRouteBinder = <
TExternalRoutes extends {
@@ -158,14 +184,12 @@ export type CreateAppRouteBinder = <
>,
) => void;
-// @public
-export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
- apis: ApiHolder;
- tree: AppTree;
- errors?: AppError[];
-};
+// @public @deprecated
+export function createSpecializedApp(
+ options?: CreateSpecializedAppOptions,
+): FinalizedSpecializedApp;
-// @public
+// @public @deprecated
export type CreateSpecializedAppOptions = {
features?: FrontendFeature[];
config?: ConfigApi;
@@ -173,8 +197,8 @@ export type CreateSpecializedAppOptions = {
advanced?: {
apis?: ApiHolder;
extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
+ | ExtensionFactoryMiddleware_2
+ | ExtensionFactoryMiddleware_2[];
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
@@ -186,11 +210,19 @@ export type ExtensionFactoryMiddleware = (
}) => ExtensionDataContainer,
context: {
node: AppNode;
- apis: ApiHolder_2;
+ apis: ApiHolder;
config?: JsonObject;
},
) => Iterable>;
+// @public
+export type FinalizedSpecializedApp = {
+ element: JSX.Element;
+ sessionState: SpecializedAppSessionState;
+ tree: AppTree;
+ errors?: AppError[];
+};
+
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise;
@@ -204,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
}) => Promise<{
info: FrontendPluginInfo;
}>;
+
+// @public
+export type PreparedSpecializedApp = {
+ getBootstrapApp(): BootstrapSpecializedApp;
+ onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
+ finalize(): FinalizedSpecializedApp;
+};
+
+// @public
+export function prepareSpecializedApp(
+ options?: PrepareSpecializedAppOptions,
+): PreparedSpecializedApp;
+
+// @public
+export type PrepareSpecializedAppOptions = {
+ features?: FrontendFeature[];
+ config?: ConfigApi;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ advanced?: {
+ sessionState?: SpecializedAppSessionState;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware_2
+ | ExtensionFactoryMiddleware_2[];
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
+};
+
+// @public
+export type SpecializedAppSessionState = {
+ $$type: '@backstage/SpecializedAppSessionState';
+};
```
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
index e52bbfa320..f5fc9f2e3a 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
});
});
});
+
+ describe('if predicate', () => {
+ function makeNodeWithEnabled(
+ enabled: AppNodeSpec['if'],
+ disabled = false,
+ ): AppNode {
+ const ext = resolveExtensionDefinition(
+ createExtension({
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [testDataRef],
+ factory: () => [testDataRef('value')],
+ }),
+ { namespace: 'test-ext' },
+ );
+ return {
+ spec: {
+ id: ext.id,
+ attachTo: ext.attachTo,
+ disabled,
+ if: enabled,
+ extension: ext as Extension,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
+ },
+ edges: { attachments: new Map() },
+ };
+ }
+
+ it('should skip a node when the predicate is not satisfied', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: [],
+ });
+ expect(tree.root.instance).toBeUndefined();
+ });
+
+ it('should instantiate a node when the predicate is satisfied', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: ['the-flag'],
+ });
+ expect(tree.root.instance).toBeDefined();
+ expect(tree.root.instance?.getData(testDataRef)).toBe('value');
+ });
+
+ it('should support $all operator across multiple flags', () => {
+ const node = makeNodeWithEnabled({
+ $all: [
+ { featureFlags: { $contains: 'flag-a' } },
+ { featureFlags: { $contains: 'flag-b' } },
+ ],
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+
+ // Only one flag active — should not instantiate
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: ['flag-a'],
+ });
+ expect(tree.root.instance).toBeUndefined();
+
+ // Both flags active — should instantiate
+ const tree2 = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
+ featureFlags: ['flag-a', 'flag-b'],
+ });
+ expect(tree2.root.instance).toBeDefined();
+ });
+
+ it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
+ const node = makeNodeWithEnabled(undefined);
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
+ featureFlags: [],
+ });
+ expect(tree.root.instance).toBeDefined();
+ });
+
+ it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
+ const node = makeNodeWithEnabled({
+ featureFlags: { $contains: 'the-flag' },
+ });
+ const tree = resolveAppTree('test-ext', [node.spec], collector);
+ // No predicateContext passed — predicate evaluation is skipped
+ instantiateAppNodeTree(tree.root, testApis, collector);
+ expect(tree.root.instance).toBeDefined();
+ });
+ });
});
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
index 9e8e3cc2eb..8cef89f497 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
@@ -19,8 +19,9 @@ import {
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
- ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
import { ExtensionFactoryMiddleware } from '../wiring/types';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
@@ -28,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createExtensionDataContainer } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
+import { evaluateFilterPredicate } from '@backstage/filter-predicates';
const INSTANTIATION_FAILED = new Error('Instantiation failed');
@@ -63,6 +65,19 @@ type Mutable = {
-readonly [P in keyof T]: T[P];
};
+type InstantiateAppNodeSubtreeOptions = {
+ rootNode: AppNode;
+ apis: ApiHolder;
+ collector: ErrorCollector;
+ extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
+ reuseExistingInstances?: boolean;
+ writeNodeInstances?: boolean;
+};
+
function resolveV1InputDataMap(
dataMap: {
[name in string]: ExtensionDataRef;
@@ -336,12 +351,28 @@ export function createAppNodeInstance(options: {
apis: ApiHolder;
attachments: ReadonlyMap;
collector: ErrorCollector;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
}): AppNodeInstance | undefined {
const { node, apis, attachments } = options;
const collector = options.collector.child({ node });
const { id, extension, config } = node.spec;
const extensionData = new Map();
const extensionDataRefs = new Set>();
+ const scopedApis: ApiHolder =
+ options.onMissingApi === undefined
+ ? apis
+ : {
+ get(apiRef) {
+ const api = apis.get(apiRef);
+ if (api === undefined) {
+ options.onMissingApi?.({
+ node,
+ apiRefId: apiRef.id,
+ });
+ }
+ return api;
+ },
+ };
let parsedConfig: { [x: string]: any };
try {
@@ -366,7 +397,7 @@ export function createAppNodeInstance(options: {
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
- apis,
+ apis: scopedApis,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
@@ -387,7 +418,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const context = {
node,
- apis,
+ apis: scopedApis,
config: parsedConfig,
inputs: resolveV2Inputs(
internalExtension.inputs,
@@ -499,6 +530,87 @@ export function createAppNodeInstance(options: {
};
}
+/**
+ * Starting at the provided node, instantiate a subtree without necessarily
+ * mutating the original app tree.
+ *
+ * @internal
+ */
+export function instantiateAppNodeSubtree(
+ options: InstantiateAppNodeSubtreeOptions,
+): AppNode | undefined {
+ const instantiatedNodes = new WeakMap();
+
+ function createInstance(node: AppNode): AppNode | undefined {
+ if (instantiatedNodes.has(node)) {
+ return instantiatedNodes.get(node) ?? undefined;
+ }
+ if (options.reuseExistingInstances !== false && node.instance) {
+ instantiatedNodes.set(node, node);
+ return node;
+ }
+ if (node.spec.disabled) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+ if (
+ options.predicateContext !== undefined &&
+ node.spec.if !== undefined &&
+ !evaluateFilterPredicate(node.spec.if, options.predicateContext)
+ ) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+
+ const instantiatedAttachments = new Map();
+
+ for (const [input, children] of node.edges.attachments) {
+ if (options.stopAtAttachment?.({ node, input })) {
+ continue;
+ }
+ const instantiatedChildren = children.flatMap(child => {
+ if (options.skipChild?.({ node, input, child })) {
+ return [];
+ }
+ const childNode = createInstance(child);
+ return childNode ? [childNode] : [];
+ });
+ if (instantiatedChildren.length > 0) {
+ instantiatedAttachments.set(input, instantiatedChildren);
+ }
+ }
+
+ const instance = createAppNodeInstance({
+ extensionFactoryMiddleware: options.extensionFactoryMiddleware,
+ node,
+ apis: options.apis,
+ attachments: instantiatedAttachments,
+ collector: options.collector,
+ onMissingApi: options.onMissingApi,
+ });
+ if (!instance) {
+ instantiatedNodes.set(node, null);
+ return undefined;
+ }
+
+ if (options.writeNodeInstances === false) {
+ const detachedNode: AppNode = {
+ spec: node.spec,
+ edges: node.edges,
+ instance,
+ };
+ instantiatedNodes.set(node, detachedNode);
+ return detachedNode;
+ }
+
+ (node as Mutable).instance = instance;
+ instantiatedNodes.set(node, node);
+ return node;
+ }
+
+ return createInstance(options.rootNode);
+}
+
/**
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
* @internal
@@ -508,40 +620,45 @@ export function instantiateAppNodeTree(
apis: ApiHolder,
collector: ErrorCollector,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
-): boolean {
- function createInstance(node: AppNode): AppNodeInstance | undefined {
- if (node.instance) {
- return node.instance;
- }
- if (node.spec.disabled) {
- return undefined;
- }
-
- const instantiatedAttachments = new Map();
-
- for (const [input, children] of node.edges.attachments) {
- const instantiatedChildren = children.flatMap(child => {
- const childInstance = createInstance(child);
- if (!childInstance) {
- return [];
- }
- return [child];
- });
- if (instantiatedChildren.length > 0) {
- instantiatedAttachments.set(input, instantiatedChildren);
+ optionsOrPredicateContext?:
+ | {
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: {
+ node: AppNode;
+ input: string;
+ child: AppNode;
+ }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
}
- }
+ | Record,
+): boolean {
+ const options: {
+ stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
+ skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
+ onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
+ predicateContext?: Record;
+ } =
+ optionsOrPredicateContext &&
+ ('stopAtAttachment' in optionsOrPredicateContext ||
+ 'skipChild' in optionsOrPredicateContext ||
+ 'onMissingApi' in optionsOrPredicateContext ||
+ 'predicateContext' in optionsOrPredicateContext)
+ ? optionsOrPredicateContext
+ : {
+ predicateContext: optionsOrPredicateContext,
+ };
- (node as Mutable).instance = createAppNodeInstance({
- extensionFactoryMiddleware,
- node,
+ return (
+ instantiateAppNodeSubtree({
+ rootNode,
apis,
- attachments: instantiatedAttachments,
collector,
- });
-
- return node.instance;
- }
-
- return createInstance(rootNode) !== undefined;
+ extensionFactoryMiddleware,
+ stopAtAttachment: options.stopAtAttachment,
+ skipChild: options.skipChild,
+ onMissingApi: options.onMissingApi,
+ predicateContext: options.predicateContext,
+ }) !== undefined
+ );
}
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
index 3ccdf5258f..ec56394b39 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
@@ -15,6 +15,8 @@
*/
import {
+ createExtension,
+ createExtensionDataRef,
createFrontendModule,
createFrontendPlugin,
Extension,
@@ -506,4 +508,204 @@ describe('resolveAppNodeSpecs', () => {
},
]);
});
+
+ it('should carry if predicate through to AppNodeSpec', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ extensions: [
+ createExtension({
+ attachTo: { id: 'app', input: 'root' },
+ if: ifPredicate,
+ output: [dataRef],
+ factory: () => [dataRef('value')],
+ }),
+ ],
+ });
+ const specs = resolveAppNodeSpecs({
+ features: [plugin],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ expect(specs).toHaveLength(1);
+ expect(specs[0].if).toEqual(ifPredicate);
+ });
+
+ it('should apply plugin if predicates to all plugin extensions', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ if: pluginIf,
+ extensions: [
+ createExtension({
+ name: 'one',
+ attachTo: { id: 'app', input: 'root' },
+ output: [dataRef],
+ factory: () => [dataRef('one')],
+ }),
+ createExtension({
+ name: 'two',
+ attachTo: { id: 'app', input: 'root' },
+ output: [dataRef],
+ factory: () => [dataRef('two')],
+ }),
+ ],
+ });
+
+ const specs = resolveAppNodeSpecs({
+ features: [plugin],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(specs).toHaveLength(2);
+ expect(specs[0].if).toEqual(pluginIf);
+ expect(specs[1].if).toEqual(pluginIf);
+ });
+
+ it('should allow plugin overrides to replace or remove plugin if predicates', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
+ const overrideIf = { permissions: { $contains: 'override.permission' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ if: pluginIf,
+ extensions: [
+ createExtension({
+ name: 'one',
+ attachTo: { id: 'app', input: 'root' },
+ output: [dataRef],
+ factory: () => [dataRef('one')],
+ }),
+ ],
+ });
+
+ const overriddenSpecs = resolveAppNodeSpecs({
+ features: [plugin.withOverrides({ if: overrideIf })],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ const clearedSpecs = resolveAppNodeSpecs({
+ features: [plugin.withOverrides({ if: undefined })],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(overriddenSpecs).toHaveLength(1);
+ expect(overriddenSpecs[0].if).toEqual(overrideIf);
+ expect(clearedSpecs).toHaveLength(1);
+ expect(clearedSpecs[0].if).toBeUndefined();
+ });
+
+ it('should merge plugin and module if predicates with extension predicates', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
+ const moduleIf = { permissions: { $contains: 'module.permission' } };
+ const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
+ const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ if: pluginIf,
+ extensions: [
+ createExtension({
+ name: 'plugin-extension',
+ attachTo: { id: 'app', input: 'root' },
+ if: extensionIf,
+ output: [dataRef],
+ factory: () => [dataRef('plugin')],
+ }),
+ createExtension({
+ name: 'module-extension',
+ attachTo: { id: 'app', input: 'root' },
+ output: [dataRef],
+ factory: () => [dataRef('base')],
+ }),
+ ],
+ });
+ const module = createFrontendModule({
+ pluginId: 'test-plugin',
+ if: moduleIf,
+ extensions: [
+ plugin.getExtension('test-plugin/module-extension').override({
+ if: moduleExtensionIf,
+ factory: () => [dataRef('module')],
+ }),
+ ],
+ });
+
+ const specs = resolveAppNodeSpecs({
+ features: [plugin, module],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(specs).toHaveLength(2);
+ expect(specs[0].id).toBe('test-plugin/plugin-extension');
+ expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] });
+ expect(specs[1].id).toBe('test-plugin/module-extension');
+ expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] });
+ });
+
+ it('should allow module extension overrides to replace or remove extension if predicates', () => {
+ const dataRef = createExtensionDataRef().with({ id: 'test.data' });
+ const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
+ const overrideIf = { permissions: { $contains: 'override.permission' } };
+ const plugin = createFrontendPlugin({
+ pluginId: 'test-plugin',
+ extensions: [
+ createExtension({
+ name: 'extension',
+ attachTo: { id: 'app', input: 'root' },
+ if: extensionIf,
+ output: [dataRef],
+ factory: () => [dataRef('base')],
+ }),
+ ],
+ });
+
+ const overriddenSpecs = resolveAppNodeSpecs({
+ features: [
+ plugin,
+ createFrontendModule({
+ pluginId: 'test-plugin',
+ extensions: [
+ plugin.getExtension('test-plugin/extension').override({
+ if: overrideIf,
+ }),
+ ],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+ const clearedSpecs = resolveAppNodeSpecs({
+ features: [
+ plugin,
+ createFrontendModule({
+ pluginId: 'test-plugin',
+ extensions: [
+ plugin.getExtension('test-plugin/extension').override({
+ if: undefined,
+ }),
+ ],
+ }),
+ ],
+ builtinExtensions: [],
+ parameters: [],
+ collector,
+ });
+
+ expect(overriddenSpecs).toHaveLength(1);
+ expect(overriddenSpecs[0].if).toEqual(overrideIf);
+ expect(clearedSpecs).toHaveLength(1);
+ expect(clearedSpecs[0].if).toBeUndefined();
+ });
});
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index a095acee0e..0e56fa4a98 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -20,6 +20,7 @@ import {
FrontendFeature,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
+import { FilterPredicate } from '@backstage/filter-predicates';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
@@ -40,6 +41,29 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin {
return plugin;
}
+function combinePredicates(
+ left: FilterPredicate | undefined,
+ right: FilterPredicate | undefined,
+) {
+ if (!left) {
+ return right;
+ }
+ if (!right) {
+ return left;
+ }
+
+ return { $all: [left, right] };
+}
+
+function getExtensionPredicate(options: {
+ internalExtension: ReturnType;
+}) {
+ if (options.internalExtension.version === 'v2') {
+ return options.internalExtension.if;
+ }
+ return undefined;
+}
+
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
@@ -79,26 +103,50 @@ export function resolveAppNodeSpecs(options: {
};
const pluginExtensions = plugins.flatMap(plugin => {
- return OpaqueFrontendPlugin.toInternal(plugin)
- .extensions.map(extension => ({
- ...extension,
- plugin,
- }))
+ const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
+ return internalPlugin.extensions
+ .map(extension => {
+ const internalExtension = toInternalExtension(extension);
+ return {
+ ...internalExtension,
+ plugin,
+ if: combinePredicates(
+ internalPlugin.if,
+ internalExtension.version === 'v2'
+ ? internalExtension.if
+ : undefined,
+ ),
+ };
+ })
.filter(filterForbidden);
});
- const moduleExtensions = modules.flatMap(mod =>
- toInternalFrontendModule(mod)
- .extensions.flatMap(extension => {
+ const moduleExtensions = modules.flatMap(mod => {
+ const internalModule = toInternalFrontendModule(mod);
+ return internalModule.extensions
+ .flatMap(extension => {
+ const internalExtension = toInternalExtension(extension);
+
// Modules for plugins that are not installed are ignored
const plugin = plugins.find(p => p.pluginId === mod.pluginId);
if (!plugin) {
return [];
}
- return [{ ...extension, plugin }];
+ return [
+ {
+ ...internalExtension,
+ plugin,
+ if: combinePredicates(
+ internalModule.if,
+ internalExtension.version === 'v2'
+ ? internalExtension.if
+ : undefined,
+ ),
+ },
+ ];
})
- .filter(filterForbidden),
- );
+ .filter(filterForbidden);
+ });
const appPlugin =
plugins.find(plugin => plugin.pluginId === 'app') ??
@@ -116,6 +164,7 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -129,6 +178,7 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -148,6 +198,9 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
+ configuredExtensions[index].params.if = getExtensionPredicate({
+ internalExtension,
+ });
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -157,6 +210,7 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
+ if: getExtensionPredicate({ internalExtension }),
config: undefined,
},
});
@@ -235,6 +289,7 @@ export function resolveAppNodeSpecs(options: {
attachTo: param.params.attachTo,
extension: param.extension,
disabled: param.params.disabled,
+ if: param.params.if,
plugin: param.params.plugin,
source: param.params.source,
config: param.params.config,
diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts
new file mode 100644
index 0000000000..65e6010182
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.test.ts
@@ -0,0 +1,72 @@
+/*
+ * 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 AnyApiFactory,
+ createApiRef,
+} from '@backstage/frontend-plugin-api';
+import {
+ FrontendApiRegistry,
+ FrontendApiResolver,
+} from './FrontendApiRegistry';
+
+describe('FrontendApiResolver', () => {
+ it('should cache falsy API values', () => {
+ const falseApiRef = createApiRef({ id: 'test.false' });
+ const falseFactoryFn = jest.fn(() => false);
+ const registry = new FrontendApiRegistry();
+
+ registry.register({
+ api: falseApiRef,
+ deps: {},
+ factory: falseFactoryFn,
+ } as AnyApiFactory);
+
+ const resolver = new FrontendApiResolver({ primaryRegistry: registry });
+
+ expect(resolver.get(falseApiRef)).toBe(false);
+ expect(resolver.get(falseApiRef)).toBe(false);
+ expect(falseFactoryFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should resolve falsy dependencies', () => {
+ const falseApiRef = createApiRef({ id: 'test.false' });
+ const dependentApiRef = createApiRef({ id: 'test.dependent' });
+ const falseFactoryFn = jest.fn(() => false);
+ const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) =>
+ deps.falseDependency === false ? 'resolved' : 'unexpected',
+ );
+ const registry = new FrontendApiRegistry();
+
+ registry.register({
+ api: falseApiRef,
+ deps: {},
+ factory: falseFactoryFn,
+ } as AnyApiFactory);
+ registry.register({
+ api: dependentApiRef,
+ deps: { falseDependency: falseApiRef },
+ factory: dependentFactoryFn,
+ } as AnyApiFactory);
+
+ const resolver = new FrontendApiResolver({ primaryRegistry: registry });
+
+ expect(resolver.get(dependentApiRef)).toBe('resolved');
+ expect(dependentFactoryFn).toHaveBeenCalledWith({
+ falseDependency: false,
+ });
+ });
+});
diff --git a/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts
new file mode 100644
index 0000000000..00d565170a
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/FrontendApiRegistry.ts
@@ -0,0 +1,139 @@
+/*
+ * 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 {
+ AnyApiFactory,
+ AnyApiRef,
+ ApiFactory,
+ ApiHolder,
+ ApiRef,
+} from '@backstage/frontend-plugin-api';
+
+export class FrontendApiRegistry {
+ private readonly factories = new Map();
+
+ register(factory: AnyApiFactory) {
+ if (this.factories.has(factory.api.id)) {
+ return false;
+ }
+
+ this.factories.set(factory.api.id, factory);
+ return true;
+ }
+
+ registerAll(factories: AnyApiFactory[]) {
+ for (const factory of factories) {
+ this.register(factory);
+ }
+ }
+
+ set(factory: AnyApiFactory) {
+ this.factories.set(factory.api.id, factory);
+ }
+
+ setAll(factories: Iterable) {
+ for (const factory of factories) {
+ this.set(factory);
+ }
+ }
+
+ get(
+ api: ApiRef,
+ ): ApiFactory | undefined {
+ const factory = this.factories.get(api.id);
+ if (!factory) {
+ return undefined;
+ }
+
+ return factory as ApiFactory;
+ }
+
+ getAllApis() {
+ const refs = new Set();
+ for (const factory of this.factories.values()) {
+ refs.add(factory.api);
+ }
+ return refs;
+ }
+}
+
+export class FrontendApiResolver implements ApiHolder {
+ private readonly apis = new Map();
+ private readonly primaryRegistry?: FrontendApiRegistry;
+ private readonly secondaryRegistry?: FrontendApiRegistry;
+ private readonly fallbackApis?: ApiHolder;
+
+ constructor(options: {
+ primaryRegistry?: FrontendApiRegistry;
+ secondaryRegistry?: FrontendApiRegistry;
+ fallbackApis?: ApiHolder;
+ }) {
+ this.primaryRegistry = options.primaryRegistry;
+ this.secondaryRegistry = options.secondaryRegistry;
+ this.fallbackApis = options.fallbackApis;
+ }
+
+ get(ref: ApiRef): T | undefined {
+ return this.load(ref);
+ }
+
+ isMaterialized(apiRefId: string) {
+ return this.apis.has(apiRefId);
+ }
+
+ invalidate(apiRefIds?: Iterable) {
+ if (apiRefIds === undefined) {
+ this.apis.clear();
+ return;
+ }
+
+ for (const apiRefId of apiRefIds) {
+ this.apis.delete(apiRefId);
+ }
+ }
+
+ private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined {
+ const existing = this.apis.get(ref.id);
+ if (this.apis.has(ref.id)) {
+ return existing as T;
+ }
+
+ const factory =
+ this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
+ if (!factory) {
+ return this.fallbackApis?.get(ref);
+ }
+
+ if (loading.includes(factory.api)) {
+ throw new Error(`Circular dependency of api factory for ${factory.api}`);
+ }
+
+ const deps = {} as { [name: string]: unknown };
+ for (const [key, depRef] of Object.entries(factory.deps)) {
+ const dep = this.load(depRef, [...loading, factory.api]);
+ if (dep === undefined) {
+ throw new Error(
+ `No API factory available for dependency ${depRef} of dependent ${factory.api}`,
+ );
+ }
+ deps[key] = dep;
+ }
+
+ const api = factory.factory(deps);
+ this.apis.set(ref.id, api);
+ return api as T;
+ }
+}
diff --git a/packages/frontend-app-api/src/wiring/apiFactories.ts b/packages/frontend-app-api/src/wiring/apiFactories.ts
new file mode 100644
index 0000000000..6ccc75b9c1
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/apiFactories.ts
@@ -0,0 +1,304 @@
+/*
+ * 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.
+ * 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 {
+ ApiBlueprint,
+ AnyApiFactory,
+ ApiHolder,
+ AppNode,
+ FrontendFeature,
+ featureFlagsApiRef,
+} from '@backstage/frontend-plugin-api';
+import { OpaqueFrontendPlugin } from '@internal/frontend';
+import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import {
+ isInternalFrontendModule,
+ toInternalFrontendModule,
+} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
+import { ErrorCollector } from './createErrorCollector';
+import {
+ FrontendApiRegistry,
+ FrontendApiResolver,
+} from './FrontendApiRegistry';
+import { type ExtensionPredicateContext } from './predicates';
+
+export type ApiFactoryEntry = {
+ node: AppNode;
+ pluginId: string;
+ factory: AnyApiFactory;
+};
+
+/**
+ * Registers feature flag declarations on an already prepared API holder.
+ *
+ * This is primarily used when bootstrap reuses APIs from a provided session
+ * state rather than building a fresh registry from bootstrap-visible factories.
+ */
+export function registerFeatureFlagDeclarationsInHolder(
+ apis: ApiHolder,
+ features: FrontendFeature[],
+) {
+ const featureFlagApi = apis.get(featureFlagsApiRef);
+ if (featureFlagApi) {
+ registerFeatureFlagDeclarations(featureFlagApi, features);
+ }
+}
+
+/**
+ * Decorates the feature flags API factory so plugin and module declarations are
+ * registered whenever that API is instantiated.
+ */
+export function wrapFeatureFlagApiFactory(
+ factory: AnyApiFactory,
+ features: FrontendFeature[],
+) {
+ if (factory.api.id !== featureFlagsApiRef.id) {
+ return factory;
+ }
+
+ return {
+ ...factory,
+ factory(deps) {
+ const featureFlagApi = factory.factory(
+ deps,
+ ) as typeof featureFlagsApiRef.T;
+ registerFeatureFlagDeclarations(featureFlagApi, features);
+ return featureFlagApi;
+ },
+ } as AnyApiFactory;
+}
+
+/**
+ * Reconciles deferred API factories into the finalized API registry.
+ *
+ * It preserves bootstrap-frozen APIs, allows safe deferred additions, and
+ * reports cases where bootstrap-visible extensions relied on APIs that only
+ * became available during finalization.
+ */
+export function syncFinalApiFactories(options: {
+ deferredApiNodes: Iterable;
+ appApiRegistry: FrontendApiRegistry;
+ apiResolver: FrontendApiResolver;
+ collector: ErrorCollector;
+ features: FrontendFeature[];
+ bootstrapApiFactoryEntries: ReadonlyMap;
+ bootstrapMissingApiAccesses: Map;
+ predicateContext: ExtensionPredicateContext;
+}) {
+ const finalApiEntries = collectApiFactoryEntries({
+ apiNodes: options.deferredApiNodes,
+ collector: options.collector,
+ predicateContext: options.predicateContext,
+ entries: new Map(options.bootstrapApiFactoryEntries),
+ });
+ // Only newly introduced or still-safe overrides are registered here. Any
+ // bootstrap-materialized API remains frozen for the lifetime of the app.
+ const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
+ const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
+ entry.factory.api.id,
+ );
+ if (!bootstrapEntry) {
+ return true;
+ }
+ if (bootstrapEntry.factory === entry.factory) {
+ return false;
+ }
+ if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
+ options.collector.report({
+ code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
+ message:
+ `Extension '${entry.node.spec.id}' tried to override API ` +
+ `'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
+ 'The bootstrap implementation was kept and the deferred override was ignored.',
+ context: {
+ node: entry.node,
+ apiRefId: entry.factory.api.id,
+ bootstrapNode: bootstrapEntry.node,
+ pluginId: entry.pluginId,
+ bootstrapPluginId: bootstrapEntry.pluginId,
+ },
+ });
+ return false;
+ }
+ return true;
+ });
+ const changedFactories = changedEntries.map(entry =>
+ wrapFeatureFlagApiFactory(entry.factory, options.features),
+ );
+ options.appApiRegistry.setAll(changedFactories);
+ options.apiResolver.invalidate(
+ changedFactories.map(factory => factory.api.id),
+ );
+ for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
+ if (
+ options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
+ !finalApiEntries.has(bootstrapAccess.apiRefId)
+ ) {
+ continue;
+ }
+
+ options.collector.report({
+ code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
+ message:
+ `Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
+ `'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
+ 'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
+ context: {
+ node: bootstrapAccess.node,
+ apiRefId: bootstrapAccess.apiRefId,
+ },
+ });
+ }
+}
+
+const EMPTY_API_HOLDER: ApiHolder = {
+ get() {
+ return undefined;
+ },
+};
+
+function registerFeatureFlagDeclarations(
+ featureFlagApi: typeof featureFlagsApiRef.T,
+ features: FrontendFeature[],
+) {
+ for (const feature of features) {
+ if (OpaqueFrontendPlugin.isType(feature)) {
+ OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({
+ name: flag.name,
+ description: flag.description,
+ pluginId: feature.id,
+ }),
+ );
+ }
+ if (isInternalFrontendModule(feature)) {
+ toInternalFrontendModule(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({
+ name: flag.name,
+ description: flag.description,
+ pluginId: feature.pluginId,
+ }),
+ );
+ }
+ }
+}
+
+/**
+ * Instantiates API extension subtrees in isolation and extracts the factories
+ * they provide without mutating the live app tree.
+ *
+ * The collected entries are later used both for bootstrap registration and for
+ * the finalization-time reconciliation of deferred API roots.
+ */
+export function collectApiFactoryEntries(options: {
+ apiNodes: Iterable;
+ collector: ErrorCollector;
+ predicateContext?: ExtensionPredicateContext;
+ entries?: Map;
+}): Map {
+ const factoriesById = options.entries ?? new Map();
+ for (const apiNode of options.apiNodes) {
+ // API extensions are instantiated in isolation so we can inspect the
+ // produced factories without mutating the live app tree.
+ const detachedApiNode = instantiateAppNodeSubtree({
+ rootNode: apiNode,
+ apis: EMPTY_API_HOLDER,
+ collector: options.collector,
+ predicateContext: options.predicateContext,
+ writeNodeInstances: false,
+ reuseExistingInstances: false,
+ });
+ if (!detachedApiNode) {
+ continue;
+ }
+ const apiFactory = detachedApiNode.instance?.getData(
+ ApiBlueprint.dataRefs.factory,
+ );
+ if (apiFactory) {
+ const apiRefId = apiFactory.api.id;
+ const ownerId = getApiOwnerId(apiRefId);
+ const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
+ const existingFactory = factoriesById.get(apiRefId);
+
+ // This allows modules to override factories provided by the plugin, but
+ // it rejects API overrides from other plugins. In the event of a
+ // conflict, the owning plugin is attempted to be inferred from the API
+ // reference ID.
+ if (existingFactory && existingFactory.pluginId !== pluginId) {
+ const shouldReplace =
+ ownerId === pluginId && existingFactory.pluginId !== ownerId;
+ const acceptedPluginId = shouldReplace
+ ? pluginId
+ : existingFactory.pluginId;
+ const rejectedPluginId = shouldReplace
+ ? existingFactory.pluginId
+ : pluginId;
+
+ options.collector.report({
+ code: 'API_FACTORY_CONFLICT',
+ message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
+ context: {
+ node: apiNode,
+ apiRefId,
+ pluginId: rejectedPluginId,
+ existingPluginId: acceptedPluginId,
+ },
+ });
+ if (shouldReplace) {
+ factoriesById.set(apiRefId, {
+ pluginId,
+ node: apiNode,
+ factory: apiFactory,
+ });
+ }
+ continue;
+ }
+
+ factoriesById.set(apiRefId, {
+ pluginId,
+ node: apiNode,
+ factory: apiFactory,
+ });
+ } else {
+ options.collector.report({
+ code: 'API_EXTENSION_INVALID',
+ message: `API extension '${apiNode.spec.id}' did not output an API factory`,
+ context: {
+ node: apiNode,
+ },
+ });
+ }
+ }
+
+ return factoriesById;
+}
+
+// TODO(Rugvip): It would be good if this was more explicit, but I think that
+// might need to wait for some future update for API factories.
+function getApiOwnerId(apiRefId: string): string {
+ const [prefix, ...rest] = apiRefId.split('.');
+ if (!prefix) {
+ return apiRefId;
+ }
+ if (prefix === 'core') {
+ return 'app';
+ }
+ if (prefix === 'plugin' && rest[0]) {
+ return rest[0];
+ }
+ return prefix;
+}
diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts
index 5f081bc092..7077e0e9c4 100644
--- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts
+++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts
@@ -82,6 +82,21 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
+ EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
+ context: { node: AppNode };
+ };
+ EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
+ context: { node: AppNode; apiRefId: string };
+ };
+ EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
+ context: {
+ node: AppNode;
+ apiRefId: string;
+ bootstrapNode: AppNode;
+ pluginId: string;
+ bootstrapPluginId: string;
+ };
+ };
// routing
ROUTE_DUPLICATE: {
context: { routeId: string };
diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts
index bfab0ae9a3..9a88590d7c 100644
--- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts
+++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { ConfigApi } from '@backstage/core-plugin-api';
+import { ConfigApi } from '@backstage/frontend-plugin-api';
import {
FrontendFeature,
FrontendPluginInfo,
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
index 51c31aca25..3e841a1283 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -27,16 +27,32 @@ import {
createExternalRouteRef,
createExtensionInput,
useRouteRef,
+ useApi,
analyticsApiRef,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
-import { screen, render } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
import { createSpecializedApp } from './createSpecializedApp';
+import {
+ FinalizedSpecializedApp,
+ prepareSpecializedApp,
+ PreparedSpecializedApp,
+} from './prepareSpecializedApp';
import { mockApis, TestApiRegistry } from '@backstage/test-utils';
-import { configApiRef, featureFlagsApiRef } from '@backstage/core-plugin-api';
+import {
+ configApiRef,
+ featureFlagsApiRef,
+ IdentityApi,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
-import { Fragment } from 'react';
+import { ComponentType, Fragment, useEffect, useState } from 'react';
+import appPluginOriginal from '@backstage/plugin-app';
+
+const signInPageComponentDataRef = createExtensionDataRef<
+ ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
+>().with({ id: 'core.sign-in-page.component' });
function makeAppPlugin(label: string = 'Test') {
return createFrontendPlugin({
@@ -50,13 +66,38 @@ function makeAppPlugin(label: string = 'Test') {
],
});
}
+
+function renderPreparedBootstrap(preparedApp: PreparedSpecializedApp) {
+ const bootstrapApp = preparedApp.getBootstrapApp();
+ const bootstrapElement = bootstrapApp.element;
+ if (!bootstrapElement) {
+ throw new Error('Expected bootstrap tree to expose a root element');
+ }
+
+ render(bootstrapElement);
+}
+
+async function waitForFinalizedApp(preparedApp: PreparedSpecializedApp) {
+ return new Promise(resolve => {
+ preparedApp.onFinalized(resolve);
+ });
+}
+
describe('createSpecializedApp', () => {
+ const appPlugin = appPluginOriginal.withOverrides({
+ extensions: [
+ appPluginOriginal.getExtension('app/layout').override({
+ factory: () => [coreExtensionData.reactElement(