Merge branch 'master' into cdedreuille/ui-neutral-bg-simplification

This commit is contained in:
Charles de Dreuille
2026-02-25 14:56:31 +00:00
469 changed files with 8444 additions and 753 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-common': patch
---
The `findOwnRootDir` utility now searches for the monorepo root by traversing up the directory tree looking for a `package.json` with `workspaces`, instead of assuming a fixed `../..` relative path. If no workspaces root is found during this traversal, `findOwnRootDir` now throws to enforce stricter validation of the repository layout.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-node': patch
---
Added `toString()` method to `Lockfile` for serializing lockfiles back to string format.
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Migrated internal versioning utilities to use `@backstage/cli-node` instead of a local implementation.
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/ui': minor
---
**BREAKING**: Removed `--bui-bg-popover` CSS token. Popover, Tooltip, Menu, and Dialog now use `--bui-bg-app` for their outer shell and `Box bg="neutral-1"` for content areas, providing better theme consistency and eliminating a redundant token.
**Migration:**
Replace any usage of `--bui-bg-popover` with `--bui-bg-neutral-1` (for content surfaces) or `--bui-bg-app` (for outer shells):
```diff
- background: var(--bui-bg-popover);
+ background: var(--bui-bg-neutral-1);
```
**Affected components:** Popover, Tooltip, Menu, Dialog
+34
View File
@@ -0,0 +1,34 @@
---
'@backstage/plugin-catalog-backend-module-github': minor
---
The default user transformer now prefers organization verified domain emails over the user's public GitHub email when populating the user entity profile. It also strips plus-addressed routing tags that GitHub adds to these emails.
If you want to retain the old behavior, you can do so with a custom user transformer using the `githubOrgEntityProviderTransformsExtensionPoint`:
```ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import { githubOrgEntityProviderTransformsExtensionPoint } from '@backstage/plugin-catalog-backend-module-github-org';
import { defaultUserTransformer } from '@backstage/plugin-catalog-backend-module-github';
export default createBackendModule({
pluginId: 'catalog',
moduleId: 'github-org-custom-transforms',
register(env) {
env.registerInit({
deps: {
transforms: githubOrgEntityProviderTransformsExtensionPoint,
},
async init({ transforms }) {
transforms.setUserTransformer(async (item, ctx) => {
const entity = await defaultUserTransformer(item, ctx);
if (entity && item.email) {
entity.spec.profile!.email = item.email;
}
return entity;
});
},
});
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Internal refactor of CLI command modules.
+39 -1
View File
@@ -210,5 +210,43 @@
"@backstage/plugin-user-settings-backend": "0.4.0",
"@backstage/plugin-user-settings-common": "0.1.0"
},
"changesets": []
"changesets": [
"add-masked-and-hidden-option",
"blue-moons-crash",
"bright-moons-open",
"brown-towns-find",
"bump-bfj-v9",
"cli-common-cached-paths",
"cli-internal-refactor",
"cli-node-parallel-helpers",
"cli-translations-export-import",
"dependabot-d2ec7e9",
"fancy-ends-turn",
"fix-frontend-feature-compat",
"fix-prettier-existence-check",
"fluffy-owls-act",
"long-hairs-throw",
"mean-fans-decide",
"metal-humans-move",
"migrate-to-target-paths",
"ninety-corners-flash",
"orange-mugs-post-1",
"orange-mugs-post-2",
"pink-terms-know",
"polite-singers-lead",
"pretty-days-taste",
"rare-adults-attack",
"renovate-8b1c21e",
"rude-groups-shout",
"scaffolder-export-form-fields-api",
"silver-pigs-remain",
"sixty-pianos-begin",
"stable-translation-plugin-app",
"stable-translation-test-utils",
"stupid-pans-hope",
"swift-flowers-grin",
"swift-ravens-jog",
"tired-bushes-write",
"twenty-worlds-create"
]
}
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-backend': minor
---
Added predicate-based entity filtering via POST /entities/by-query endpoint.
Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support.
The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided.
-1
View File
@@ -1 +0,0 @@
Follow the instructions at /.github/copilot-instructions.md
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
-7
View File
@@ -1,7 +0,0 @@
---
description: General project guidelines for Backstage development
globs:
alwaysApply: true
---
Follow the instructions at /.github/copilot-instructions.md
-46
View File
@@ -1,49 +1,3 @@
Backstage is an open platform for building developer portals. This is a TypeScript monorepo using Yarn workspaces.
## Key Directories
- `/packages`: Core framework packages (prefixed `@backstage/`)
- `/plugins`: Plugin packages (prefixed `@backstage/plugin-*`)
- `/packages/app` and `/packages/backend`: Example app for local development
- `/packages/app`: Main example app using the new frontend system
- `/packages/app-legacy`: Example app using the old frontend system
- `/docs`: Documentation files
Packages prefixed with `core-` (e.g., `@backstage/core-plugin-api`) are part of the old frontend system. Packages prefixed with `frontend-` (e.g., `@backstage/frontend-plugin-api`) are part of the new frontend system (NFS). Packages prefixed with `backend-` (e.g., `@backstage/backend-plugin-api`) are part of the backend system.
## Code Standards
The following files contain guidelines for the project:
- `/CONTRIBUTING.md`: comprehensive contribution guidelines.
- `/STYLE.md`: guidelines for code style.
- `/REVIEWING.md`: guidelines for pull requests and writing changesets.
- `/SECURITY.md`: guidelines for security.
- `/docs/architecture-decisions/`: contains the architecture decisions for the project.
## Development Flow
Before any of these commands can be run, you need to run `yarn install` in the project root.
- Build: There is no need to build the project during development, and it is verified automatically in the CI pipeline.
- Test: Use `yarn test --no-watch <path>` in the project root to run tests. The path can be either a single file or a directory. Always provide a path, avoid running all tests.
- Type checking: Use `yarn tsc` in the project root to run the type checker.
- Code formatting: Use `yarn prettier --write <path>` to format code.
- Lint: Use `yarn lint --fix` in the project root to run the linter.
- API reports: Before submitting a pull request with changes to any package in the workspace, run `yarn build:api-reports` in the project root to generate API reports for all packages.
- Dev server: Use `yarn start` to run the example app locally (frontend on :3000, backend on :7007).
- Create: Use `yarn new` to scaffold new plugins, packages, or modules.
You MUST NOT create a release by running `yarn changesets version` or `yarn release` as part of any changes. Releases are created by separate workflows.
All changes that affect the published version of packages in the `/packages` and `/plugins` directories must be accompanied by a changeset. Only non-private packages require changesets. See the guidelines in `/CONTRIBUTING.md#creating-changesets` for information on how to write good changesets. Changesets are stored in the `/.changeset` directory. Breaking changes must be accompanied by a `minor` version bump for packages below version `1.0.0`, or a `major` version bump for packages at version `1.0.0` or higher.
Never update ESLint, Prettier, or TypeScript configuration files unless specifically requested.
## Repository Structure
See `/docs/contribute/project-structure.md` for a detailed description of the repository structure.
## Pull Request Reviews
When reviewing a pull request you MUST calculate a priority score between 0 and 100, where a higher score indicates higher priority. You MUST include the score as an HTML comment at the very beginning of the "PR Overview" section, using the following format: `<!-- priority: <score> -->`.
+53
View File
@@ -0,0 +1,53 @@
Backstage is an open platform for building developer portals. This is a TypeScript monorepo using Yarn workspaces.
## Key Directories
- `/packages`: Core framework packages (prefixed `@backstage/`)
- `/plugins`: Plugin packages (prefixed `@backstage/plugin-*`)
- `/packages/app` and `/packages/backend`: Example app for local development
- `/packages/app`: Main example app using the new frontend system
- `/packages/app-legacy`: Example app using the old frontend system
- `/docs`: Documentation files
Packages prefixed with `core-` (e.g., `@backstage/core-plugin-api`) are part of the old frontend system. Packages prefixed with `frontend-` (e.g., `@backstage/frontend-plugin-api`) are part of the new frontend system (NFS). Packages prefixed with `backend-` (e.g., `@backstage/backend-plugin-api`) are part of the backend system.
## Code Standards
The following files contain guidelines for the project:
- `/CONTRIBUTING.md`: comprehensive contribution guidelines.
- `/STYLE.md`: guidelines for code style.
- `/REVIEWING.md`: guidelines for pull requests and writing changesets.
- `/SECURITY.md`: guidelines for security.
- `/docs/architecture-decisions/`: contains the architecture decisions for the project.
When writing or generating code, always match the existing coding style of each individual package and file. Different packages in the monorepo may have different conventions — consistency within a package is more important than consistency across the repo.
When writing or generating tests, prefer fewer thorough tests with multiple assertions over many small tests. When using React Testing Library, prefer using `screen` and `.findBy*` queries over `waitFor`, and avoid adding test IDs to the implementation.
## Development Flow
Before any of these commands can be run, you need to run `yarn install` in the project root.
- Build: There is no need to build the project during development, and it is verified automatically in the CI pipeline.
- Test: Use `CI=1 yarn test <path>` in the project root to run tests. The path can be either a single file or a directory. Always provide a path, avoid running all tests.
- Type checking: Use `yarn tsc` in the project root to run the type checker. Do not try to run it somewhere else than the project root and do not supply any options.
- Code formatting: Use `yarn prettier --write <...paths>` to format code. Run it explicitly for file paths that you know are changed, not for entire folders - otherwise it may change formatting of unrelated files.
- Lint: Use `yarn lint --fix` in the project root to run the linter.
- API reports: Before submitting a pull request with changes to any package in the workspace, run `yarn build:api-reports` in the project root to generate API reports for all packages.
- Dev server: Use `yarn start` to run the example app locally (frontend on :3000, backend on :7007).
- Create: Use `yarn new` to scaffold new plugins, packages, or modules.
You MUST NOT run builds or create a release by running `yarn build`, `yarn changesets version`, or `yarn release` as part of any changes. Builds and releases are made by separate workflows.
All changes that affect the published version of packages in the `/packages` and `/plugins` directories must be accompanied by a changeset. Only non-private packages require changesets. See the guidelines in `/CONTRIBUTING.md#creating-changesets` for information on how to write good changesets. Changesets are stored in the `/.changeset` directory and should be created by writing changeset files directly — never use the changeset CLI. Breaking changes must be accompanied by a `minor` version bump for packages below version `1.0.0`, or a `major` version bump for packages at version `1.0.0` or higher. For non-breaking changes that introduce new APIs or features, use `minor` for packages at version `1.0.0` or higher, and `patch` for packages below `1.0.0`. Each changeset message should be relevant to the specific package it targets and written for Backstage adopters as the audience — avoid referencing internal implementation details. If a change spans multiple packages you often need to create separate changesets to make sure they are tailored to each package.
When creating pull requests, use the template at `/.github/PULL_REQUEST_TEMPLATE.md`.
Never update ESLint, Prettier, or TypeScript configuration files unless specifically requested.
Never make changes to the release notes in `/docs/releases` unless explicitly asked. These document past releases and should not be updated based on newer changes.
## Repository Structure
See `/docs/contribute/project-structure.md` for a detailed description of the repository structure.
-1
View File
@@ -156,7 +156,6 @@ These colors form a layered neutral scale for your application backgrounds. `--b
| Token Name | Description |
| ----------------------------- | ------------------------------------------------------------ |
| `--bui-bg-app` | The base background color of your Backstage instance. |
| `--bui-bg-popover` | The background color used for popovers, tooltips, and menus. |
| `--bui-bg-neutral-1` | First elevated layer. Use for cards, dialogs, and panels. |
| `--bui-bg-neutral-1-hover` | Hover state for elements on neutral-1. |
| `--bui-bg-neutral-1-pressed` | Pressed state for elements on neutral-1. |
+185
View File
@@ -210,6 +210,191 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie
it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters,
as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration.
### `POST /entities/by-query`
This supports the same features as the `GET` variant, but in a `POST` body to
not have to abide by URL length limits. Additionally, it supports advanced, more
expressive querying format - see below. The response format is identical.
#### Querying by filter predicate
You can pass in a filter predicate to select a subset of entities in the
catalog. They are comprised of an optional logical expression tree (using
`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers
(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`).
This is an example of what such a filter predicate expression might look like:
```js
{
"query": {
"$all": [
{
"kind": "Component",
"spec.type": { "$in": ["service", "website"] }
},
{
"$not": {
"metadata.annotations.backstage.io/orphan": "true"
}
}
]
}
}
```
A filter set is an object whose keys are dot separated paths into an object, and
the values are either primitives (string, number, or boolean) or custom matchers
as per below. An example of a simple such filter set is:
```js
// All of the following must be true for a given entity (there's an
// implicit AND between them)
{
// The kind field is matched against a literal, case insensitively
"kind": "Component",
// The type field inside the spec is matched using a custom matcher, see below
"spec.type": { "$in": ["service", "website"] }
}
```
The root of the query is always an object, whether there is a logic expression
tree or not. Nodes with a single key that starts with a `$` sign have special
meaning.
- `$not`: Logical negation.
Its value must be a single expression. Example:
```js
// Matches entities that do NOT have kind Component
{
"$not": {
"kind": "Component",
}
}
```
Note that `$not` cannot be used in a right hand side value matcher.
```js
// ❌ WRONG
{ "kind": { "$not": "Component" } }
// ✅ CORRECT
{ "$not": { "kind": "Component" } }
```
- `$all`: Require that all given expressions match each entity.
Its value must be an array of expressions. Example:
```js
// Matches entities that BOTH have kind Component and type website
{
"$all": [
{ "kind": "Component" },
{ "spec.type": "website" }
]
}
```
An empty array always matches every entity.
- `$any`: Require that at least one of a set of expressions match a given entity.
Its value must be an array of expressions. Example:
```js
// Matches entities that EITHER have kind Component or type website
{
"$any": [
{ "kind": "Component" },
{ "spec.type": "website" }
]
}
```
An empty array never matches anything.
- `$exists`: Assert on the existence of fields.
Its value is either `true`, meaning that the field must exist on the entity
(no matter what its value), or `false`, meaning that it must not exist.
Example:
```js
// Matches entities that DO NOT have that annotation, ignoring what the
// value might be
{
"metadata.annotations.backstage.io/orphan": {
"$exists": false
},
}
```
- `$in`: Assert that a field has any of a set of primitive values.
Its value must be an array of string, number, and/or boolean values. Example:
```js
// Matches entities whose type is EITHER service or website
{
"spec.type": {
"$in": ["service", "website"]
}
}
```
The matching is case insensitive. An empty array never matches anything.
- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text.
Its value is a string. Example:
```js
// Matches entities whose project slug annotation starts with "backstage/"
{
"metadata.annotations.github.com/project-slug": {
"$hasPrefix": "backstage/"
}
}
```
The matching is case insensitive, and captures both exact matches and strings
that start with the given prefix.
- `$contains`: Assert that an array contains an element that matches the given expression.
There is only limited support for this matcher. One use case is for relations:
```js
{
// Specifically type and (optionally) targetRef supported, and only
// with equality or "$in" for the targetRef
"relations": {
"$contains": {
"type": "ownedBy",
"targetRef": {
"$in": ["user:default/foo", "group:default/bar"]
}
}
}
}
```
The other use case is for arrays where you match with a primitive value, such
as tags. Example:
```js
{
// Works for array fields whose items are primitive values
// (typically strings, but numbers and booleans are also supported)
"metadata.tags": {
"$contains": "java"
}
}
```
### `GET /entities`
Lists entities.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "root",
"version": "1.48.0",
"version": "1.49.0-next.0",
"backstage": {
"cli": {
"new": {
+11
View File
@@ -1,5 +1,16 @@
# @backstage/app-defaults
## 1.7.6-next.0
### Patch Changes
- Updated dependencies
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-permission-react@0.4.41-next.0
## 1.7.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/app-defaults",
"version": "1.7.5",
"version": "1.7.6-next.0",
"description": "Provides the default wiring of a Backstage App",
"backstage": {
"role": "web-library"
+8
View File
@@ -1,5 +1,13 @@
# app-example-plugin
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/core-components@0.18.8-next.0
## 0.0.32
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-example-plugin",
"version": "0.0.32",
"version": "0.0.33-next.0",
"description": "Backstage internal example plugin",
"backstage": {
"role": "frontend-plugin",
+44
View File
@@ -1,5 +1,49 @@
# example-app-legacy
## 0.2.119-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-mui-to-bui@0.2.5-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.2.118
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app-legacy",
"version": "0.2.118",
"version": "0.2.119-next.0",
"backstage": {
"role": "frontend"
},
+50
View File
@@ -1,5 +1,55 @@
# example-app
## 0.0.33-next.0
### Patch Changes
- Updated dependencies
- @backstage/ui@0.12.1-next.0
- @backstage/plugin-search-react@1.10.5-next.0
- @backstage/plugin-search@1.6.2-next.0
- @backstage/plugin-api-docs@0.13.5-next.0
- @backstage/cli@0.35.5-next.0
- @backstage/frontend-plugin-api@0.14.2-next.0
- @backstage/plugin-scaffolder@1.35.5-next.0
- @backstage/plugin-app@0.4.1-next.0
- @backstage/plugin-app-visualizer@0.2.1-next.0
- @backstage/plugin-catalog@1.33.1-next.0
- @backstage/plugin-catalog-react@2.0.1-next.0
- @backstage/plugin-techdocs@1.17.1-next.0
- @backstage/app-defaults@1.7.6-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/core-app-api@1.19.6-next.0
- @backstage/core-compat-api@0.5.9-next.0
- @backstage/core-components@0.18.8-next.0
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/frontend-app-api@0.15.1-next.0
- @backstage/frontend-defaults@0.4.1-next.0
- @backstage/integration-react@1.2.16-next.0
- @backstage/theme@0.7.2
- @backstage/plugin-app-react@0.2.1-next.0
- @backstage/plugin-auth@0.1.6-next.0
- @backstage/plugin-auth-react@0.1.25-next.0
- @backstage/plugin-catalog-common@1.1.8
- @backstage/plugin-catalog-graph@0.5.8-next.0
- @backstage/plugin-catalog-import@0.13.11-next.0
- @backstage/plugin-catalog-unprocessed-entities@0.2.27-next.0
- @backstage/plugin-devtools@0.1.37-next.0
- @backstage/plugin-home@0.9.3-next.0
- @backstage/plugin-home-react@0.1.36-next.0
- @backstage/plugin-kubernetes@0.12.17-next.0
- @backstage/plugin-kubernetes-cluster@0.0.35-next.0
- @backstage/plugin-notifications@0.5.15-next.0
- @backstage/plugin-org@0.6.50-next.0
- @backstage/plugin-permission-react@0.4.41-next.0
- @backstage/plugin-scaffolder-react@1.19.8-next.0
- @backstage/plugin-search-common@1.2.22
- @backstage/plugin-signals@0.0.29-next.0
- @backstage/plugin-techdocs-module-addons-contrib@1.1.34-next.0
- @backstage/plugin-techdocs-react@1.3.9-next.0
- @backstage/plugin-user-settings@0.9.1-next.0
## 0.0.32
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-app",
"version": "0.0.32",
"version": "0.0.33-next.0",
"backstage": {
"role": "frontend"
},
+9
View File
@@ -1,5 +1,14 @@
# @backstage/backend-app-api
## 1.5.1-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
## 1.5.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-app-api",
"version": "1.5.0",
"version": "1.5.1-next.0",
"description": "Core API used by Backstage backend apps",
"backstage": {
"role": "node-library"
+25
View File
@@ -1,5 +1,30 @@
# @backstage/backend-defaults
## 0.15.3-next.0
### Patch Changes
- 6738cf0: build(deps): bump `minimatch` from 9.0.5 to 10.2.1
- d933f62: Add configurable throttling and retry mechanism for GitLab integration.
- b99158a: Fixed `yarn backstage-cli config:check --strict --config app-config.yaml` config validation error by adding
an optional `default` type discriminator to PostgreSQL connection configuration,
allowing `config:check` to properly validate `default` connection configurations.
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-node@0.2.19-next.0
- @backstage/integration@1.21.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/backend-dev-utils@0.1.7
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/integration-aws-node@0.1.20
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-node@0.10.11-next.0
## 0.15.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-defaults",
"version": "0.15.2",
"version": "0.15.3-next.0",
"description": "Backend defaults used by Backstage backend apps",
"backstage": {
"role": "node-library"
@@ -1,5 +1,31 @@
# @backstage/backend-dynamic-feature-service
## 0.7.10-next.0
### Patch Changes
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/cli-node@0.2.19-next.0
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/plugin-catalog-backend@3.5.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-openapi-utils@0.6.7-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-app-node@0.1.43-next.0
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-backend@0.5.12-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-scaffolder-node@0.12.6-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-search-common@1.2.22
## 0.7.9
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-dynamic-feature-service",
"version": "0.7.9",
"version": "0.7.10-next.0",
"description": "Backstage dynamic feature service",
"backstage": {
"role": "node-library"
@@ -1,5 +1,14 @@
# @backstage/backend-openapi-utils
## 0.6.7-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
## 0.6.6
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-openapi-utils",
"version": "0.6.6",
"version": "0.6.7-next.0",
"description": "OpenAPI typescript support.",
"backstage": {
"role": "node-library"
+14
View File
@@ -1,5 +1,19 @@
# @backstage/backend-plugin-api
## 1.7.1-next.0
### Patch Changes
- 1ee5b28: Adds an alpha `MetricsService` to provide a unified interface for metrics instrumentation across Backstage plugins.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
## 1.7.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-plugin-api",
"version": "1.7.0",
"version": "1.7.1-next.0",
"description": "Core API used by Backstage backend plugins",
"backstage": {
"role": "node-library"
+16
View File
@@ -1,5 +1,21 @@
# @backstage/backend-test-utils
## 1.11.1-next.0
### Patch Changes
- 1ee5b28: Adds a new metrics service mock to be leveraged in tests
- Updated dependencies
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/backend-app-api@1.5.1-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-events-node@0.4.20-next.0
- @backstage/plugin-permission-common@0.9.6
## 1.11.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/backend-test-utils",
"version": "1.11.0",
"version": "1.11.1-next.0",
"description": "Test helpers library for Backstage backends",
"backstage": {
"role": "node-library"
+42
View File
@@ -1,5 +1,47 @@
# example-backend
## 0.0.48-next.0
### Patch Changes
- Updated dependencies
- @backstage/backend-defaults@0.15.3-next.0
- @backstage/plugin-auth-backend@0.27.1-next.0
- @backstage/plugin-catalog-backend@3.5.0-next.0
- @backstage/plugin-scaffolder-backend@3.1.4-next.0
- @backstage/backend-plugin-api@1.7.1-next.0
- @backstage/plugin-mcp-actions-backend@0.1.10-next.0
- @backstage/catalog-model@1.7.6
- @backstage/plugin-app-backend@0.5.12-next.0
- @backstage/plugin-auth-backend-module-github-provider@0.5.1-next.0
- @backstage/plugin-auth-backend-module-guest-provider@0.2.17-next.0
- @backstage/plugin-auth-backend-module-openshift-provider@0.1.5-next.0
- @backstage/plugin-auth-node@0.6.14-next.0
- @backstage/plugin-catalog-backend-module-backstage-openapi@0.5.12-next.0
- @backstage/plugin-catalog-backend-module-openapi@0.2.20-next.0
- @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.18-next.0
- @backstage/plugin-catalog-backend-module-unprocessed@0.6.9-next.0
- @backstage/plugin-devtools-backend@0.5.15-next.0
- @backstage/plugin-events-backend@0.5.12-next.0
- @backstage/plugin-events-backend-module-google-pubsub@0.2.1-next.0
- @backstage/plugin-kubernetes-backend@0.21.2-next.0
- @backstage/plugin-notifications-backend@0.6.3-next.0
- @backstage/plugin-permission-backend@0.7.10-next.0
- @backstage/plugin-permission-backend-module-allow-all-policy@0.2.17-next.0
- @backstage/plugin-permission-common@0.9.6
- @backstage/plugin-permission-node@0.10.11-next.0
- @backstage/plugin-proxy-backend@0.6.11-next.0
- @backstage/plugin-scaffolder-backend-module-github@0.9.7-next.0
- @backstage/plugin-scaffolder-backend-module-notifications@0.1.20-next.0
- @backstage/plugin-search-backend@2.0.13-next.0
- @backstage/plugin-search-backend-module-catalog@0.3.13-next.0
- @backstage/plugin-search-backend-module-elasticsearch@1.8.1-next.0
- @backstage/plugin-search-backend-module-explore@0.3.12-next.0
- @backstage/plugin-search-backend-module-techdocs@0.4.12-next.0
- @backstage/plugin-search-backend-node@1.4.2-next.0
- @backstage/plugin-signals-backend@0.3.13-next.0
- @backstage/plugin-techdocs-backend@2.1.6-next.0
## 0.0.47
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.0.47",
"version": "0.0.48-next.0",
"backstage": {
"role": "backend"
},
+10
View File
@@ -1,5 +1,15 @@
# @backstage/catalog-client
## 1.13.1-next.0
### Patch Changes
- d2494d6: Minor update to catalog client docs
- Updated dependencies
- @backstage/catalog-model@1.7.6
- @backstage/errors@1.2.7
- @backstage/filter-predicates@0.1.0
## 1.13.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-client",
"version": "1.13.0",
"version": "1.13.1-next.0",
"description": "An isomorphic client for the catalog backend",
"backstage": {
"role": "common-library"
+3 -2
View File
@@ -7,8 +7,8 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common';
import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { FilterPredicate } from '@backstage/filter-predicates';
import { SerializedError } from '@backstage/errors';
import type { FilterPredicate } from '@backstage/filter-predicates';
import type { SerializedError } from '@backstage/errors';
// @public
export type AddLocationRequest = {
@@ -320,6 +320,7 @@ export type QueryEntitiesInitialRequest = {
limit?: number;
offset?: number;
filter?: EntityFilterQuery;
query?: FilterPredicate;
orderFields?: EntityOrderQuery;
fullTextFilter?: {
term: string;
@@ -540,6 +540,350 @@ describe('CatalogClient', () => {
});
});
describe('queryEntities with predicate-based queries (POST endpoint)', () => {
const defaultResponse = {
items: [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'service-1',
namespace: 'default',
},
spec: {
type: 'service',
owner: 'team-a',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'service-2',
namespace: 'default',
},
spec: {
type: 'service',
owner: 'team-b',
},
},
],
totalItems: 2,
pageInfo: {},
};
it('should use POST endpoint when query is provided', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.method).toBe('POST');
expect(req.body).toMatchObject({
query: { kind: 'component' },
limit: 20,
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
const response = await client.queryEntities({
query: { kind: 'component' },
limit: 20,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
expect(response.items).toEqual(defaultResponse.items);
expect(response.totalItems).toBe(2);
});
it('should support $all operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $any operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $not operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$not: { 'spec.lifecycle': 'experimental' },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$not: { 'spec.lifecycle': 'experimental' },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $exists operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
'spec.owner': { $exists: true },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
'spec.owner': { $exists: true },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $in operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support complex nested predicates', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{
$not: {
'spec.lifecycle': 'experimental',
},
},
],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{
$not: {
'spec.lifecycle': 'experimental',
},
},
],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send orderFields with correct format', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.orderBy).toEqual([
{ field: 'metadata.name', order: 'asc' },
]);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
orderFields: { field: 'metadata.name', order: 'asc' },
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send multiple orderFields with correct format', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.orderBy).toEqual([
{ field: 'metadata.name', order: 'asc' },
{ field: 'spec.type', order: 'desc' },
]);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
orderFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'spec.type', order: 'desc' },
],
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send limit and offset parameters in the body', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.limit).toBe(50);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
limit: 50,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should paginate using POST when cursor contains a query', async () => {
// Simulate a cursor that contains a query predicate (as the server would encode it)
const cursorPayload = Buffer.from(
JSON.stringify({
orderFields: [],
orderFieldValues: [],
isPrevious: false,
query: { kind: 'component' },
totalItems: 100,
}),
).toString('base64');
const page2Response = {
items: [
{
apiVersion: '1',
kind: 'Component',
metadata: { name: 'service-3', namespace: 'default' },
},
],
totalItems: 100,
pageInfo: {},
};
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.method).toBe('POST');
expect(req.body).toMatchObject({ cursor: cursorPayload });
return res(ctx.json(page2Response));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
const response = await client.queryEntities({
cursor: cursorPayload,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
expect(response.items).toEqual(page2Response.items);
expect(response.totalItems).toBe(100);
});
it('should use GET endpoint for cursor without query', async () => {
// A cursor that does NOT contain a query field should go to GET
const cursorPayload = Buffer.from(
JSON.stringify({
orderFields: [],
orderFieldValues: [],
isPrevious: false,
totalItems: 50,
}),
).toString('base64');
const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) =>
res(
ctx.json({
items: [],
totalItems: 50,
pageInfo: {},
}),
),
);
const mockedPostEndpoint = jest.fn();
server.use(
rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint),
rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint),
);
await client.queryEntities({ cursor: cursorPayload });
expect(mockedGetEndpoint).toHaveBeenCalledTimes(1);
expect(mockedPostEndpoint).not.toHaveBeenCalled();
});
it('should handle errors from POST endpoint', async () => {
const mockedEndpoint = jest
.fn()
.mockImplementation((_req, res, ctx) => res(ctx.status(400)));
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await expect(() =>
client.queryEntities({ query: { kind: 'component' } }),
).rejects.toThrow(/Request failed with 400/);
});
});
describe('streamEntities', () => {
const defaultResponse: QueryEntitiesResponse = {
items: [
+107 -6
View File
@@ -20,7 +20,8 @@ import {
parseEntityRef,
stringifyLocationRef,
} from '@backstage/catalog-model';
import { ResponseError } from '@backstage/errors';
import { InputError, ResponseError } from '@backstage/errors';
import { FilterPredicate } from '@backstage/filter-predicates';
import {
AddLocationRequest,
AddLocationResponse,
@@ -46,10 +47,17 @@ import {
StreamEntitiesRequest,
ValidateEntityResponse,
} from './types/api';
import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils';
import {
convertFilterToPredicate,
isQueryEntitiesInitialRequest,
splitRefsIntoChunks,
cursorContainsQuery,
} from './utils';
import {
DefaultApiClient,
GetEntitiesByQuery,
GetLocationsByQueryRequest,
QueryEntitiesByPredicateRequest,
TypedResponse,
} from './schema/openapi';
import type {
@@ -266,11 +274,26 @@ export class CatalogClient implements CatalogApi {
request: QueryEntitiesRequest = {},
options?: CatalogRequestOptions,
): Promise<QueryEntitiesResponse> {
const params: Partial<
Parameters<typeof this.apiClient.getEntitiesByQuery>[0]['query']
> = {};
const isInitialRequest = isQueryEntitiesInitialRequest(request);
if (isQueryEntitiesInitialRequest(request)) {
// Route to POST endpoint if query predicate is provided (initial request)
if (isInitialRequest && request.query) {
return this.queryEntitiesByPredicate(request, options);
}
// Route to POST endpoint if cursor contains a query predicate (pagination)
// TODO(freben): It's costly and non-opaque to have to introspect the cursor
// like this. It should be refactored in the future to not need this.
// Suggestion: make the GET and POST endpoints understand the same cursor
// format, and pick which one to call ONLY based on whether the cursor size
// risks hitting url length limits
if (!isInitialRequest && cursorContainsQuery(request.cursor)) {
return this.queryEntitiesByPredicate(request, options);
}
const params: Partial<GetEntitiesByQuery['query']> = {};
if (isInitialRequest) {
const {
fields = [],
filter,
@@ -320,6 +343,84 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* Query entities using predicate-based filters (POST endpoint).
* @internal
*/
private async queryEntitiesByPredicate(
request: QueryEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<QueryEntitiesResponse> {
const body: QueryEntitiesByPredicateRequest = {};
if (isQueryEntitiesInitialRequest(request)) {
const {
filter,
query,
limit,
offset,
orderFields,
fullTextFilter,
fields,
} = request;
let filterPredicate: FilterPredicate | undefined;
if (query !== undefined) {
if (
typeof query !== 'object' ||
query === null ||
Array.isArray(query)
) {
throw new InputError('Query must be an object');
}
filterPredicate = query;
}
if (filter !== undefined) {
const converted = convertFilterToPredicate(filter);
filterPredicate = filterPredicate
? { $all: [filterPredicate, converted] }
: converted;
}
if (filterPredicate !== undefined) {
body.query = filterPredicate as unknown as { [key: string]: any };
}
if (limit !== undefined) {
body.limit = limit;
}
if (offset !== undefined) {
body.offset = offset;
}
if (orderFields !== undefined) {
body.orderBy = [orderFields].flat();
}
if (fullTextFilter) {
body.fullTextFilter = fullTextFilter;
}
if (fields?.length) {
body.fields = fields;
}
} else {
body.cursor = request.cursor;
if (request.limit !== undefined) {
body.limit = request.limit;
}
if (request.fields?.length) {
body.fields = request.fields;
}
}
const res = await this.requestRequired(
await this.apiClient.queryEntitiesByPredicate({ body }, options),
);
return {
items: res.items,
totalItems: res.totalItems,
pageInfo: res.pageInfo,
};
}
/**
* {@inheritdoc CatalogApi.getEntityByRef}
*/
@@ -29,6 +29,7 @@ import { Entity } from '../models/Entity.model';
import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model';
@@ -139,6 +140,12 @@ export type GetEntityFacets = {
filter?: Array<string>;
};
};
/**
* @public
*/
export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
};
/**
* @public
*/
@@ -449,6 +456,31 @@ export class DefaultApiClient {
});
}
/**
* Query entities using predicate-based filters.
* @param queryEntitiesByPredicateRequest -
*/
public async queryEntitiesByPredicate(
// @ts-ignore
request: QueryEntitiesByPredicate,
options?: RequestOptions,
): Promise<TypedResponse<EntitiesQueryResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/entities/by-query`;
const uri = parser.parse(uriTemplate).expand({});
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify(request.body),
});
}
/**
* Refresh the entity related to entityRef.
* @param refreshEntityRequest -
@@ -0,0 +1,38 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
/**
* @public
*/
export interface QueryEntitiesByPredicateRequest {
cursor?: string;
limit?: number;
offset?: number;
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,6 +14,14 @@
* limitations under the License.
*/
export { Lockfile } from './Lockfile';
export { fetchPackageInfo, mapDependencies } from './packages';
export type { YarnInfoInspectData } from './packages';
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestFullTextFilter {
term?: string;
fields?: Array<string>;
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestOrderByInner {
field: string;
order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum;
}
/**
* @public
*/
export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum =
| 'asc'
| 'desc';
@@ -45,6 +45,9 @@ export * from '../models/LocationsQueryResponse.model';
export * from '../models/LocationsQueryResponsePageInfo.model';
export * from '../models/ModelError.model';
export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -683,6 +683,83 @@ describe('InMemoryCatalogClient', () => {
]);
});
it('filters by predicate query', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { kind: 'CustomKind' },
});
expect(result.items).toEqual([entity1, entity3]);
expect(result.totalItems).toBe(2);
});
it('filters by predicate query with $all', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }],
},
});
expect(result.items).toEqual([entity1, entity3]);
});
it('filters by predicate query with $any', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
expect(result.items).toEqual([entity1, entity3, entity4]);
});
it('filters by predicate query with $not', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$all: [
{ kind: 'CustomKind' },
{ $not: { 'spec.lifecycle': 'production' } },
],
},
});
expect(result.items).toEqual([]);
});
it('filters by predicate query with $in', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { 'spec.type': { $in: ['service', 'library'] } },
});
expect(result.items).toEqual([entity1, entity2, entity3]);
});
it('filters by predicate query with $exists', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { 'spec.lifecycle': { $exists: false } },
});
expect(result.items).toEqual([entity4]);
});
it('preserves query predicate through cursor pagination', async () => {
const client = new InMemoryCatalogClient({ entities });
const page1 = await client.queryEntities({
query: { kind: 'CustomKind' },
orderFields: { field: 'metadata.name', order: 'asc' },
limit: 1,
});
expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']);
expect(page1.totalItems).toBe(2);
expect(page1.pageInfo.nextCursor).toBeDefined();
const page2 = await client.queryEntities({
cursor: page1.pageInfo.nextCursor!,
limit: 1,
});
expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']);
expect(page2.pageInfo.nextCursor).toBeUndefined();
});
it('throws InputError for invalid cursor', async () => {
const client = new InMemoryCatalogClient({ entities });
await expect(
@@ -51,6 +51,10 @@ import {
NotFoundError,
NotImplementedError,
} from '@backstage/errors';
import {
FilterPredicate,
filterPredicateToFilterFunction,
} from '@backstage/filter-predicates';
import lodash from 'lodash';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch';
@@ -373,6 +377,7 @@ export class InMemoryCatalogClient implements CatalogApi {
): Promise<QueryEntitiesResponse> {
// Decode query parameters from cursor or from the request directly
let filter: EntityFilterQuery | undefined;
let query: FilterPredicate | undefined;
let orderFields: EntityOrderQuery | undefined;
let fullTextFilter: { term: string; fields?: string[] } | undefined;
let offset: number;
@@ -386,12 +391,14 @@ export class InMemoryCatalogClient implements CatalogApi {
throw new InputError('Invalid cursor');
}
filter = deserializeFilter(c.filter as any[]);
query = c.query as FilterPredicate | undefined;
orderFields = c.orderFields as EntityOrderQuery | undefined;
fullTextFilter = c.fullTextFilter as typeof fullTextFilter;
offset = c.offset as number;
limit = request.limit;
} else {
filter = request?.filter;
query = request?.query;
orderFields = request?.orderFields;
fullTextFilter = request?.fullTextFilter;
offset = request?.offset ?? 0;
@@ -401,6 +408,11 @@ export class InMemoryCatalogClient implements CatalogApi {
// Apply filter
let items = this.#entities.filter(createFilter(filter));
// Apply predicate-based query filter
if (query) {
items = items.filter(filterPredicateToFilterFunction(query));
}
// Apply full-text filter, defaulting to the sort field or metadata.uid
if (fullTextFilter) {
const orderFieldsList = orderFields ? [orderFields].flat() : [];
@@ -432,6 +444,7 @@ export class InMemoryCatalogClient implements CatalogApi {
const cursorBase = {
filter: serializeFilter(filter),
query,
orderFields,
fullTextFilter,
totalItems,
+39 -7
View File
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
import type { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import type { SerializedError } from '@backstage/errors';
import type {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
} from '@backstage/plugin-catalog-common';
import { FilterPredicate } from '@backstage/filter-predicates';
import type { FilterPredicate } from '@backstage/filter-predicates';
/**
* This symbol can be used in place of a value when passed to filters in e.g.
@@ -418,16 +418,43 @@ export type QueryEntitiesRequest =
* The method takes this type in an initial pagination request,
* when requesting the first batch of entities.
*
* The properties filter, sortField, query and sortFieldOrder, are going
* The properties filter, query, sortField and sortFieldOrder, are going
* to be immutable for the entire lifecycle of the following requests.
*
* @remarks
*
* Either `filter` or `query` can be provided, or even both:
* - `filter`: Uses the traditional key-value filter syntax (GET endpoint)
* - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint)
*
* @public
*/
export type QueryEntitiesInitialRequest = {
fields?: string[];
limit?: number;
offset?: number;
/**
* Traditional key-value based filter.
*/
filter?: EntityFilterQuery;
/**
* Predicate-based filter with operators for logical expressions (`$all`,
* `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and
* (partially) `$contains`).
*
* @example
* ```typescript
* {
* query: {
* $all: [
* { kind: 'component' },
* { 'spec.type': { $in: ['service', 'website'] } }
* ]
* }
* }
* ```
*/
query?: FilterPredicate;
orderFields?: EntityOrderQuery;
fullTextFilter?: {
term: string;
@@ -567,6 +594,7 @@ export interface CatalogApi {
* const response = await catalogClient.queryEntities({
* filter: [{ kind: 'group' }],
* limit: 20,
* fields: ['metadata', 'kind'],
* fullTextFilter: {
* term: 'A',
* },
@@ -583,11 +611,15 @@ export interface CatalogApi {
*
* ```
* const secondBatchResponse = await catalogClient
* .queryEntities({ cursor: response.nextCursor });
* .queryEntities({
* cursor: response.nextCursor,
* limit: 20,
* fields: ['metadata', 'kind'],
* });
* ```
*
* secondBatchResponse will contain the next batch of (maximum) 20 entities,
* together with a prevCursor property, useful to fetch the previous batch.
* `secondBatchResponse` will contain the next batch of (maximum) 20 entities,
* together with a `prevCursor` property, useful to fetch the previous batch.
*
* @public
*
+81 -1
View File
@@ -14,7 +14,87 @@
* limitations under the License.
*/
import { splitRefsIntoChunks } from './utils';
import { CATALOG_FILTER_EXISTS } from './types/api';
import { convertFilterToPredicate, splitRefsIntoChunks } from './utils';
describe('convertFilterToPredicate', () => {
it('converts a single string value', () => {
expect(convertFilterToPredicate({ kind: 'component' })).toEqual({
kind: 'component',
});
});
it('converts multiple keys into $all', () => {
expect(
convertFilterToPredicate({
kind: 'component',
'spec.type': 'service',
}),
).toEqual({
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
});
});
it('converts an array of string values into $in', () => {
expect(
convertFilterToPredicate({ 'spec.type': ['service', 'website'] }),
).toEqual({
'spec.type': { $in: ['service', 'website'] },
});
});
it('converts CATALOG_FILTER_EXISTS into $exists', () => {
expect(
convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }),
).toEqual({
'spec.owner': { $exists: true },
});
});
it('converts an array of records into $any (OR)', () => {
expect(
convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]),
).toEqual({
$any: [{ kind: 'component' }, { kind: 'api' }],
});
});
it('converts array of records with multiple keys each', () => {
expect(
convertFilterToPredicate([
{ kind: 'component', 'spec.type': 'service' },
{ kind: 'api' },
]),
).toEqual({
$any: [
{ $all: [{ kind: 'component' }, { 'spec.type': 'service' }] },
{ kind: 'api' },
],
});
});
it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => {
expect(
convertFilterToPredicate({
'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'],
}),
).toEqual({
'spec.owner': { $exists: true },
});
});
it('converts a single-element array filter without wrapping in $any', () => {
expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({
kind: 'component',
});
});
it('ignores entries with no valid values', () => {
expect(
convertFilterToPredicate({ kind: 'component', other: [] as string[] }),
).toEqual({ kind: 'component' });
});
});
describe('splitRefsIntoChunks', () => {
it('splits by count limit', () => {
+58
View File
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import type {
FilterPredicate,
FilterPredicateExpression,
} from '@backstage/filter-predicates';
import {
CATALOG_FILTER_EXISTS,
EntityFilterQuery,
QueryEntitiesCursorRequest,
QueryEntitiesInitialRequest,
QueryEntitiesRequest,
@@ -26,6 +32,58 @@ export function isQueryEntitiesInitialRequest(
return !(request as QueryEntitiesCursorRequest).cursor;
}
/**
* Check if a cursor contains a predicate query by attempting to decode it.
* @internal
*/
export function cursorContainsQuery(cursor: string): boolean {
try {
const decoded = JSON.parse(atob(cursor));
return 'query' in decoded;
} catch {
return false;
}
}
/**
* Converts an {@link EntityFilterQuery} into a predicate query object.
* @internal
*/
export function convertFilterToPredicate(filter: EntityFilterQuery):
| FilterPredicateExpression
| {
$all: FilterPredicate[];
}
| {
$any: FilterPredicate[];
} {
const records = [filter].flat();
const clauses = records.map(record => {
const parts: FilterPredicateExpression[] = [];
for (const [key, value] of Object.entries(record)) {
const values = [value].flat();
const strings = values.filter((v): v is string => typeof v === 'string');
const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS);
if (hasExists) {
// Ignore whether there ALSO were some strings - that would boil down to
// just existence anyway since there's effectively an OR between them
parts.push({ [key]: { $exists: true } } as FilterPredicateExpression);
} else if (strings.length === 1) {
parts.push({ [key]: strings[0] } as FilterPredicateExpression);
} else if (strings.length > 1) {
parts.push({ [key]: { $in: strings } } as FilterPredicateExpression);
}
}
return parts.length === 1 ? parts[0] : { $all: parts };
});
return clauses.length === 1 ? clauses[0] : { $any: clauses };
}
/**
* Takes a set of entity refs, and splits them into chunks (groups) such that
* the total string length in each chunk does not exceed the default Express.js
+34
View File
@@ -1,5 +1,39 @@
# @backstage/cli-common
## 0.2.0-next.0
### Minor Changes
- 56bd494: Added `targetPaths` and `findOwnPaths` as replacements for `findPaths`, with a cleaner separation between target project paths and package-relative paths.
To migrate existing `findPaths` usage:
```ts
// Before
import { findPaths } from '@backstage/cli-common';
const paths = findPaths(__dirname);
// After — for target project paths (cwd-based):
import { targetPaths } from '@backstage/cli-common';
// paths.targetDir → targetPaths.dir
// paths.targetRoot → targetPaths.rootDir
// paths.resolveTarget('src') → targetPaths.resolve('src')
// paths.resolveTargetRoot('yarn.lock') → targetPaths.resolveRoot('yarn.lock')
// After — for package-relative paths:
import { findOwnPaths } from '@backstage/cli-common';
const own = findOwnPaths(__dirname);
// paths.ownDir → own.dir
// paths.ownRoot → own.rootDir
// paths.resolveOwn('config/jest.js') → own.resolve('config/jest.js')
// paths.resolveOwnRoot('tsconfig.json') → own.resolveRoot('tsconfig.json')
```
### Patch Changes
- Updated dependencies
- @backstage/errors@1.2.7
## 0.1.18
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/cli-common",
"version": "0.1.18",
"version": "0.2.0-next.0",
"description": "Common functionality used by cli, backend, and create-app",
"backstage": {
"role": "node-library"
+5 -5
View File
@@ -16,18 +16,18 @@
/* eslint-disable no-restricted-syntax */
import { resolve as resolvePath } from 'node:path';
import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths';
import { findPaths, findRootPath, findOwnRootDir, findOwnPaths } from './paths';
describe('paths', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('findOwnDir and findOwnRootDir should find owns paths', () => {
const dir = findOwnDir(__dirname);
const root = findOwnRootDir(dir);
it('findOwnPaths and findOwnRootDir should find own paths', () => {
const own = findOwnPaths(__dirname);
const root = findOwnRootDir(own.dir);
expect(dir).toBe(resolvePath(__dirname, '..'));
expect(own.dir).toBe(resolvePath(__dirname, '..'));
expect(root).toBe(resolvePath(__dirname, '../../..'));
});
+17 -6
View File
@@ -119,7 +119,23 @@ export function findOwnRootDir(ownDir: string) {
);
}
return resolvePath(ownDir, '../..');
const rootDir = findRootPath(ownDir, pkgJsonPath => {
try {
const content = fs.readFileSync(pkgJsonPath, 'utf8');
const data = JSON.parse(content);
return Boolean(data.workspaces);
} catch (error) {
throw new Error(
`Failed to read package.json at '${pkgJsonPath}', ${error}`,
);
}
});
if (!rootDir) {
throw new Error(`No monorepo root found when searching from '${ownDir}'`);
}
return rootDir;
}
// Hierarchical directory cache shared across all OwnPathsImpl instances.
@@ -199,11 +215,6 @@ class OwnPathsImpl implements OwnPaths {
};
}
// Finds the root of a given package
export function findOwnDir(searchDir: string) {
return OwnPathsImpl.findDir(searchDir);
}
// Used by the test utility in testUtils.ts to override targetPaths
export let targetPathsOverride: TargetPaths | undefined;
+11
View File
@@ -1,5 +1,16 @@
# @backstage/cli-node
## 0.2.19-next.0
### Patch Changes
- 06c2015: Added `runConcurrentTasks` and `runWorkerQueueThreads` utilities, moved from the `@backstage/cli` internal code.
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
## 0.2.18
### Patch Changes
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/cli-node",
"version": "0.2.18",
"version": "0.2.19-next.0",
"description": "Node.js library for Backstage CLIs",
"backstage": {
"role": "node-library"
@@ -35,6 +35,7 @@
"@backstage/errors": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@yarnpkg/lockfile": "^1.1.0",
"@yarnpkg/parsers": "^3.0.0",
"fs-extra": "^11.2.0",
"semver": "^7.5.3",
@@ -43,6 +44,7 @@
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/test-utils": "workspace:^"
"@backstage/test-utils": "workspace:^",
"@types/yarnpkg__lockfile": "^1.1.4"
}
}
+1
View File
@@ -111,6 +111,7 @@ export class Lockfile {
keys(): IterableIterator<string>;
static load(path: string): Promise<Lockfile>;
static parse(content: string): Lockfile;
toString(): string;
}
// @public
@@ -15,6 +15,7 @@
*/
import { Lockfile } from './Lockfile';
import { createMockDirectory } from '@backstage/backend-test-utils';
const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -29,7 +30,74 @@ __metadata:
cacheKey: 8
`;
describe('New Lockfile', () => {
const mockLegacy = `${LEGACY_HEADER}
a@^1:
version "1.0.1"
resolved "https://my-registry/a-1.0.01.tgz#abc123"
integrity sha512-xyz
dependencies:
b "^2"
b@2.0.x:
version "2.0.1"
b@^2:
version "2.0.0"
`;
const mockModern = `${MODERN_HEADER}
a@^1:
version: 1.0.1
dependencies:
b: ^2
integrity: sha512-xyz
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
"b@2.0.x, b@^2.0.1":
version: 2.0.1
b@^2:
version: 2.0.0
`;
describe('Lockfile', () => {
const mockDir = createMockDirectory();
it('should load and serialize a legacy lockfile', async () => {
mockDir.setContent({
'yarn.lock': mockLegacy,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockLegacy);
});
it('should load and serialize a modern lockfile', async () => {
mockDir.setContent({
'yarn.lock': mockModern,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockModern);
});
});
describe('Lockfile advanced', () => {
describe('diff', () => {
const lockfileLegacyA = Lockfile.parse(`${LEGACY_HEADER}
a@^1:
+26 -2
View File
@@ -14,12 +14,22 @@
* limitations under the License.
*/
import { parseSyml } from '@yarnpkg/parsers';
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
import crypto from 'node:crypto';
import fs from 'fs-extra';
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
const NEW_HEADER = `${[
`# This file is generated by running "yarn install" inside your project.\n`,
`# Manual changes might be lost - proceed with caution!\n`,
].join(``)}\n`;
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
/** @internal */
type LockfileData = {
[entry: string]: {
@@ -97,6 +107,8 @@ export class Lockfile {
* @public
*/
static parse(content: string): Lockfile {
const legacy = LEGACY_REGEX.test(content);
let data: LockfileData;
try {
data = parseSyml(content);
@@ -130,18 +142,21 @@ export class Lockfile {
}
}
return new Lockfile(packages, data);
return new Lockfile(packages, data, legacy);
}
private readonly packages: Map<string, LockfileQueryEntry[]>;
private readonly data: LockfileData;
private readonly legacy: boolean;
private constructor(
packages: Map<string, LockfileQueryEntry[]>,
data: LockfileData,
legacy: boolean = false,
) {
this.packages = packages;
this.data = data;
this.legacy = legacy;
}
/** Returns the name of all packages available in the lockfile */
@@ -154,6 +169,15 @@ export class Lockfile {
return this.packages.keys();
}
/**
* Serialize the lockfile back to a string.
*/
toString(): string {
return this.legacy
? legacyStringifyLockfile(this.data)
: NEW_HEADER + stringifySyml(this.data);
}
/**
* Creates a simplified dependency graph from the lockfile data, where each
* key is a package, and the value is a set of all packages that it depends on
+29
View File
@@ -1,5 +1,34 @@
# @backstage/cli
## 0.35.5-next.0
### Patch Changes
- 246877a: Updated dependency `bfj` to `^9.0.2`.
- bba2e49: Internal refactor to use new concurrency utilities from `@backstage/cli-node`.
- fd50cb3: Added `translations export` and `translations import` commands for managing translation files.
The `translations export` command discovers all `TranslationRef` definitions across frontend plugin dependencies and exports their default messages as JSON files. The `translations import` command generates `TranslationResource` wiring code from translated JSON files, ready to be plugged into the app.
Both commands support a `--pattern` option for controlling the message file layout, for example `--pattern '{lang}/{id}.json'` for language-based directory grouping.
- 6738cf0: build(deps): bump `minimatch` from 9.0.5 to 10.2.1
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- de62a9d: Upgraded `commander` dependency from `^12.0.0` to `^14.0.3` across all CLI packages.
- 092b41f: Updated dependency `webpack` to `~5.105.0`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/cli-node@0.2.19-next.0
- @backstage/eslint-plugin@0.2.2-next.0
- @backstage/integration@1.21.0-next.0
- @backstage/config-loader@1.10.9-next.0
- @backstage/catalog-model@1.7.6
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/module-federation-common@0.1.0
- @backstage/release-manifests@0.0.13
- @backstage/types@1.2.2
## 0.35.4
### Patch Changes
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/cli",
"version": "0.35.4",
"version": "0.35.5-next.0",
"description": "CLI for developing Backstage plugins and apps",
"backstage": {
"role": "cli"
@@ -77,8 +77,6 @@
"@types/webpack-env": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.16.0",
"@yarnpkg/lockfile": "^1.1.0",
"@yarnpkg/parsers": "^3.0.0",
"bfj": "^9.0.2",
"buffer": "^6.0.3",
"chalk": "^4.0.0",
@@ -182,7 +180,6 @@
"@types/tar": "^6.1.1",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-sources": "^3.2.3",
"@types/yarnpkg__lockfile": "^1.1.4",
"del": "^8.0.0",
"esbuild-loader": "^4.0.0",
"eslint-webpack-plugin": "^4.2.0",
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { packageVersions, createPackageVersionProvider } from './version';
import { Lockfile } from './versioning';
import { Lockfile } from '@backstage/cli-node';
import corePluginApiPkg from '@backstage/core-plugin-api/package.json';
import { createMockDirectory } from '@backstage/backend-test-utils';
+1 -1
View File
@@ -17,7 +17,7 @@
import fs from 'fs-extra';
import semver from 'semver';
import { findOwnPaths } from '@backstage/cli-common';
import { Lockfile } from './versioning';
import { Lockfile } from '@backstage/cli-node';
/* eslint-disable-next-line no-restricted-syntax */
const ownPaths = findOwnPaths(__dirname);
@@ -1,102 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Lockfile } from './Lockfile';
import { createMockDirectory } from '@backstage/backend-test-utils';
const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
const MODERN_HEADER = `# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 6
cacheKey: 8
`;
const mockA = `${LEGACY_HEADER}
a@^1:
version "1.0.1"
resolved "https://my-registry/a-1.0.01.tgz#abc123"
integrity sha512-xyz
dependencies:
b "^2"
b@2.0.x:
version "2.0.1"
b@^2:
version "2.0.0"
`;
describe('Lockfile', () => {
const mockDir = createMockDirectory();
it('should load and serialize mockA', async () => {
mockDir.setContent({
'yarn.lock': mockA,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockA);
});
});
const mockANew = `${MODERN_HEADER}
a@^1:
version: 1.0.1
dependencies:
b: ^2
integrity: sha512-xyz
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
"b@2.0.x, b@^2.0.1":
version: 2.0.1
b@^2:
version: 2.0.0
`;
describe('New Lockfile', () => {
const mockDir = createMockDirectory();
it('should load and serialize mockANew', async () => {
mockDir.setContent({
'yarn.lock': mockANew,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockANew);
});
});
-138
View File
@@ -1,138 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
type LockfileData = {
[entry: string]: {
version: string;
resolved?: string;
integrity?: string /* old */;
checksum?: string /* new */;
dependencies?: { [name: string]: string };
peerDependencies?: { [name: string]: string };
};
};
type LockfileQueryEntry = {
range: string;
version: string;
dataKey: string;
};
// the new yarn header is handled out of band of the parsing
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
const NEW_HEADER = `${[
`# This file is generated by running "yarn install" inside your project.\n`,
`# Manual changes might be lost - proceed with caution!\n`,
].join(``)}\n`;
// taken from yarn parser package
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
// these are special top level yarn keys.
// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9
const SPECIAL_OBJECT_KEYS = [
`__metadata`,
`version`,
`resolution`,
`dependencies`,
`peerDependencies`,
`dependenciesMeta`,
`peerDependenciesMeta`,
`binaries`,
];
export class Lockfile {
static async load(path: string) {
const lockfileContents = await fs.readFile(path, 'utf8');
return Lockfile.parse(lockfileContents);
}
static parse(content: string) {
const legacy = LEGACY_REGEX.test(content);
let data: LockfileData;
try {
data = parseSyml(content);
} catch (err) {
throw new Error(`Failed yarn.lock parse, ${err}`);
}
const packages = new Map<string, LockfileQueryEntry[]>();
for (const [key, value] of Object.entries(data)) {
if (SPECIAL_OBJECT_KEYS.includes(key)) continue;
const [, name, ranges] = ENTRY_PATTERN.exec(key) ?? [];
if (!name) {
throw new Error(`Failed to parse yarn.lock entry '${key}'`);
}
let queries = packages.get(name);
if (!queries) {
queries = [];
packages.set(name, queries);
}
for (let range of ranges.split(/\s*,\s*/)) {
if (range.startsWith(`${name}@`)) {
range = range.slice(`${name}@`.length);
}
if (range.startsWith('npm:')) {
range = range.slice('npm:'.length);
}
queries.push({ range, version: value.version, dataKey: key });
}
}
return new Lockfile(packages, data, legacy);
}
private readonly packages: Map<string, LockfileQueryEntry[]>;
private readonly data: LockfileData;
private readonly legacy: boolean;
private constructor(
packages: Map<string, LockfileQueryEntry[]>,
data: LockfileData,
legacy: boolean = false,
) {
this.packages = packages;
this.data = data;
this.legacy = legacy;
}
/** Get the entries for a single package in the lockfile */
get(name: string): LockfileQueryEntry[] | undefined {
return this.packages.get(name);
}
/** Returns the name of all packages available in the lockfile */
keys(): IterableIterator<string> {
return this.packages.keys();
}
toString() {
return this.legacy
? legacyStringifyLockfile(this.data)
: NEW_HEADER + stringifySyml(this.data);
}
}
@@ -17,7 +17,7 @@
import {
productionPack,
revertProductionPack,
} from '../../../../modules/build/lib/packager/productionPack';
} from '../../lib/packager/productionPack';
import { targetPaths } from '@backstage/cli-common';
import fs from 'fs-extra';
+53 -1
View File
@@ -16,7 +16,7 @@
import { Command, Option } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
import { configOption } from '../config';
export function registerPackageCommands(command: Command) {
@@ -197,6 +197,58 @@ export const buildPlugin = createCliPlugin({
},
});
reg.addCommand({
path: ['package', 'clean'],
description: 'Delete cache directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/clean'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['package', 'prepack'],
description: 'Prepares a package for packaging before publishing',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'pre'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['package', 'postpack'],
description: 'Restores the changes made by the prepack command',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'post'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['repo', 'clean'],
description: 'Delete cache and output directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/repo/clean'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['build-workspace'],
description:
+1 -1
View File
@@ -16,7 +16,7 @@
import { createCliPlugin } from '../../wiring/factory';
import yargs from 'yargs';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export const configOption = [
'--config <path>',
@@ -15,7 +15,7 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'new',
@@ -17,8 +17,11 @@
import { version as cliVersion } from '../../../../package.json';
import os from 'node:os';
import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common';
import { Lockfile } from '../../../lib/versioning';
import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
import {
BackstagePackageJson,
Lockfile,
PackageGraph,
} from '@backstage/cli-node';
import { minimatch } from 'minimatch';
import fs from 'fs-extra';
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import yargs from 'yargs';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'info',
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export function registerPackageLintCommand(command: Command) {
command.arguments('[directories...]');
@@ -31,7 +31,7 @@ import {
} from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { publishPreflightCheck } from '../../lib/publishing';
import { publishPreflightCheck } from '../../../build/lib/publishing';
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
+1 -53
View File
@@ -15,50 +15,11 @@
*/
import { Command } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'maintenance',
init: async reg => {
reg.addCommand({
path: ['package', 'clean'],
description: 'Delete cache directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/clean'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['package', 'prepack'],
description: 'Prepares a package for packaging before publishing',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'pre'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['package', 'postpack'],
description: 'Restores the changes made by the prepack command',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'post'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['repo', 'fix'],
description: 'Automatically fix packages in the project',
@@ -79,19 +40,6 @@ export default createCliPlugin({
},
});
reg.addCommand({
path: ['repo', 'clean'],
description: 'Delete cache and output directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/repo/clean'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
});
reg.addCommand({
path: ['repo', 'list-deprecations'],
description: 'List deprecations',
@@ -19,7 +19,7 @@ import * as runObj from '@backstage/cli-common';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump';
import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils';
import { YarnInfoInspectData } from '../../../../lib/versioning/packages';
import { YarnInfoInspectData } from '../../lib/versioning/packages';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { NotFoundError } from '@backstage/errors';
@@ -69,8 +69,8 @@ jest.mock('@backstage/cli-common', () => {
});
const mockFetchPackageInfo = jest.fn();
jest.mock('../../../../lib/versioning/packages', () => {
const actual = jest.requireActual('../../../../lib/versioning/packages');
jest.mock('../../lib/versioning/packages', () => {
const actual = jest.requireActual('../../lib/versioning/packages');
return {
...actual,
fetchPackageInfo: (name: string) => mockFetchPackageInfo(name),
@@ -31,13 +31,12 @@ import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'node:path';
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
import { Lockfile, runConcurrentTasks } from '@backstage/cli-node';
import {
fetchPackageInfo,
Lockfile,
mapDependencies,
YarnInfoInspectData,
} from '../../../../lib/versioning';
import { runConcurrentTasks } from '@backstage/cli-node';
} from '../../lib/versioning/packages';
import {
getManifestByReleaseLine,
getManifestByVersion,
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'migrate',
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
import { NotImplementedError } from '@backstage/errors';
export default createCliPlugin({
@@ -24,7 +24,7 @@ import startCase from 'lodash/startCase';
import upperCase from 'lodash/upperCase';
import upperFirst from 'lodash/upperFirst';
import lowerFirst from 'lodash/lowerFirst';
import { Lockfile } from '../../../../lib/versioning';
import { Lockfile } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { createPackageVersionProvider } from '../../../../lib/version';
+1 -1
View File
@@ -15,7 +15,7 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'test',
@@ -15,7 +15,7 @@
*/
import yargs from 'yargs';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../lib/lazy';
import { lazy } from '../../wiring/lazy';
import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath';
export default createCliPlugin({
+1 -1
View File
@@ -20,7 +20,7 @@ import { CommandRegistry } from './CommandRegistry';
import { Command } from 'commander';
import { version } from '../lib/version';
import chalk from 'chalk';
import { exitWithError } from '../lib/errors';
import { exitWithError } from './errors';
import { ForwardedError } from '@backstage/errors';
import { isPromise } from 'node:util/types';
@@ -15,7 +15,7 @@
*/
import { assertError } from '@backstage/errors';
import { exitWithError } from '../lib/errors';
import { exitWithError } from './errors';
type ActionFunc = (...args: any[]) => Promise<void>;
type ActionExports<TModule extends object> = {
+9
View File
@@ -1,5 +1,14 @@
# @backstage/codemods
## 0.1.55-next.0
### Patch Changes
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- de62a9d: Upgraded `commander` dependency from `^12.0.0` to `^14.0.3` across all CLI packages.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
## 0.1.54
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/codemods",
"version": "0.1.54",
"version": "0.1.55-next.0",
"description": "A collection of codemods for Backstage projects",
"backstage": {
"role": "cli"
+11
View File
@@ -1,5 +1,16 @@
# @backstage/config-loader
## 1.10.9-next.0
### Patch Changes
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
- Updated dependencies
- @backstage/cli-common@0.2.0-next.0
- @backstage/config@1.3.6
- @backstage/errors@1.2.7
- @backstage/types@1.2.2
## 1.10.8
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/config-loader",
"version": "1.10.8",
"version": "1.10.9-next.0",
"description": "Config loading functionality used by Backstage backend, and CLI",
"backstage": {
"role": "node-library"
+10
View File
@@ -1,5 +1,15 @@
# @backstage/core-app-api
## 1.19.6-next.0
### Patch Changes
- Updated dependencies
- @backstage/config@1.3.6
- @backstage/core-plugin-api@1.12.4-next.0
- @backstage/types@1.2.2
- @backstage/version-bridge@1.0.12
## 1.19.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/core-app-api",
"version": "1.19.5",
"version": "1.19.6-next.0",
"description": "Core app API used by Backstage apps",
"backstage": {
"role": "web-library"

Some files were not shown because too many files have changed in this diff Show More