cli: add Backstage Yarn plugin support for templating

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-10-01 15:09:57 +02:00
parent 9556a3bb16
commit d14ef24882
8 changed files with 346 additions and 52 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added automatic detection and support for the Backstage Yarn plugin when generating new packages with `yarn new`. When the plugin is installed, new packages will automatically use `backstage:^` ranges for `@backstage/*` dependencies.
+10
View File
@@ -147,3 +147,13 @@ The `role` property in the template yaml file is used to determine what input wi
| `plugin-web-library` | `pluginId` | `plugins` | none |
| `plugin-node-library` | `pluginId` | `plugins` | none |
| `plugin-common-library` | `pluginId` | `plugins` | none |
## Dependency Versioning
The `yarn new` command automatically detects if the [Backstage Yarn plugin](https://github.com/backstage/backstage/tree/master/packages/yarn-plugin) is installed in your repository and adjusts dependency versioning accordingly.
When the Backstage Yarn plugin is installed (detected via `.yarnrc.yml`), `yarn new` will generate `backstage:^` ranges for all `@backstage/*` dependencies. This ensures that new packages use the same Backstage version as defined in your `backstage.json` file.
When the plugin is not installed, `yarn new` uses the standard npm version ranges (e.g., `^1.0.0`) for all dependencies, maintaining backward compatibility.
Regardless of plugin installation, `workspace:` ranges found in your `yarn.lock` file will always take precedence over both `backstage:^` and npm ranges. This ensures that packages within monorepos continue to use workspace linking when available.
+106
View File
@@ -79,4 +79,110 @@ describe('createPackageVersionProvider', () => {
`^${corePluginApiPkg.version}`,
);
});
describe('with backstage protocol options', () => {
it('should return backstage:^ for @backstage packages when preferBackstageProtocol is true', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('@backstage/core-plugin-api')).toBe('backstage:^');
expect(provider('@backstage/cli')).toBe('backstage:^');
});
it('should not return backstage:^ for non-@backstage packages even when preferBackstageProtocol is true', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"react@^18.0.0":
version "18.0.0"
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
"@internal/library@workspace:packages/internal":
version "0.0.0-use.local"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('react', '18.0.0')).toBe('^18.0.0');
expect(provider('@backstage/core-plugin-api')).toBe('backstage:^');
expect(provider('@internal/library')).toBe('workspace:^');
});
it('should prefer workspace ranges over backstage protocol', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"react@workspace:packages/internal":
version "0.0.0-use.local"
"@backstage/core-plugin-api@workspace:packages/internal":
version "0.0.0-use.local"
"@internal/library@workspace:packages/internal":
version "0.0.0-use.local"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: true,
});
expect(provider('react')).toBe('workspace:^');
expect(provider('@backstage/core-plugin-api')).toBe('workspace:^');
expect(provider('@internal/library')).toBe('workspace:^');
});
it('should not use backstage protocol when preferBackstageProtocol is false', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: false,
});
expect(provider('@backstage/core-plugin-api')).toBe(
`^${corePluginApiPkg.version}`,
);
});
it('should not use backstage protocol when options are not provided', async () => {
mockDir.setContent({
'yarn.lock': `${HEADER}
"@backstage/core-plugin-api@^1.0.0":
version "1.0.0"
`,
});
const lockfilePath = mockDir.resolve('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const provider = createPackageVersionProvider(lockfile);
expect(provider('@backstage/core-plugin-api')).toBe(
`^${corePluginApiPkg.version}`,
);
});
});
});
+22 -10
View File
@@ -89,23 +89,35 @@ export function findVersion() {
export const version = findVersion();
export const isDev = fs.pathExistsSync(paths.resolveOwn('src'));
export function createPackageVersionProvider(lockfile?: Lockfile) {
export function createPackageVersionProvider(
lockfile?: Lockfile,
options?: {
preferBackstageProtocol?: boolean;
},
) {
return (name: string, versionHint?: string): string => {
const packageVersion = packageVersions[name];
// 1) workspace precedence (existing logic) - check this first
const lockfileEntries = lockfile?.get(name);
const lockfileEntry = lockfileEntries?.find(entry =>
entry.range.startsWith('workspace:'),
);
if (lockfileEntry) {
return 'workspace:^';
}
// 2) backstage:^ when plugin is present and allowed
if (options?.preferBackstageProtocol && name.startsWith('@backstage/')) {
return 'backstage:^';
}
// 3) fallback to current npm resolution
const targetVersion = versionHint || packageVersion;
if (!targetVersion) {
throw new Error(`No version available for package ${name}`);
}
const lockfileEntries = lockfile?.get(name);
for (const specifier of ['^', '~', '*']) {
const range = `workspace:${specifier}`;
if (lockfileEntries?.some(entry => entry.range === range)) {
return range;
}
}
const validRanges = lockfileEntries?.filter(entry =>
semver.satisfies(targetVersion, entry.range),
);
+131
View File
@@ -0,0 +1,131 @@
/*
* 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 { createMockDirectory } from '@backstage/backend-test-utils';
import { getHasYarnPlugin } from './yarnPlugin';
const mockDir = createMockDirectory();
jest.mock('./paths', () => ({
paths: {
resolveTargetRoot(filename: string) {
return mockDir.resolve(filename);
},
},
}));
describe('getHasYarnPlugin', () => {
beforeEach(() => {
mockDir.clear();
});
it('should return false when .yarnrc.yml does not exist', async () => {
mockDir.setContent({});
const result = await getHasYarnPlugin();
expect(result).toBe(false);
});
it('should return false when .yarnrc.yml is empty', async () => {
mockDir.setContent({
'.yarnrc.yml': '',
});
const result = await getHasYarnPlugin();
expect(result).toBe(false);
});
it('should return false when plugins array is empty', async () => {
mockDir.setContent({
'.yarnrc.yml': 'plugins: []',
});
const result = await getHasYarnPlugin();
expect(result).toBe(false);
});
it('should return false when plugins array does not contain backstage plugin', async () => {
mockDir.setContent({
'.yarnrc.yml': `
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
`,
});
const result = await getHasYarnPlugin();
expect(result).toBe(false);
});
it('should return true when backstage plugin is present', async () => {
mockDir.setContent({
'.yarnrc.yml': `
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
`,
});
const result = await getHasYarnPlugin();
expect(result).toBe(true);
});
it('should return true when backstage plugin is the only plugin', async () => {
mockDir.setContent({
'.yarnrc.yml': `
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
`,
});
const result = await getHasYarnPlugin();
expect(result).toBe(true);
});
it('should throw error when .yarnrc.yml has invalid content', async () => {
mockDir.setContent({
'.yarnrc.yml': 'invalid: yaml: content: [',
});
await expect(getHasYarnPlugin()).rejects.toThrow();
});
it('should throw error when .yarnrc.yml has unexpected structure', async () => {
mockDir.setContent({
'.yarnrc.yml': `
plugins: "not an array"
`,
});
await expect(getHasYarnPlugin()).rejects.toThrow(
'Unexpected content in .yarnrc.yml',
);
});
it('should handle plugins with different structure', async () => {
mockDir.setContent({
'.yarnrc.yml': `
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
`,
});
const result = await getHasYarnPlugin();
expect(result).toBe(true);
});
});
+66
View File
@@ -0,0 +1,66 @@
/*
* 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 yaml from 'yaml';
import z from 'zod';
import { paths } from './paths';
const yarnRcSchema = z.object({
plugins: z
.array(
z.object({
path: z.string(),
}),
)
.optional(),
});
/**
* Detects whether the Backstage Yarn plugin is installed in the target repository.
*
* @returns Promise<boolean> - true if the plugin is installed, false otherwise
*/
export async function getHasYarnPlugin(): Promise<boolean> {
const yarnRcPath = paths.resolveTargetRoot('.yarnrc.yml');
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
if (e.code === 'ENOENT') {
// gracefully continue in case the file doesn't exist
return '';
}
throw e;
});
if (!yarnRcContent) {
return false;
}
const parseResult = yarnRcSchema.safeParse(yaml.parse(yarnRcContent));
if (!parseResult.success) {
throw new Error(
`Unexpected content in .yarnrc.yml: ${parseResult.error.toString()}`,
);
}
const yarnRc = parseResult.data;
const backstagePlugin = yarnRc.plugins?.some(
plugin => plugin.path === '.yarn/plugins/@yarnpkg/plugin-backstage.cjs',
);
return Boolean(backstagePlugin);
}
@@ -21,11 +21,10 @@ import chalk from 'chalk';
import { minimatch } from 'minimatch';
import semver from 'semver';
import { OptionValues } from 'commander';
import yaml from 'yaml';
import z from 'zod';
import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'path';
import { paths } from '../../../../lib/paths';
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
import {
mapDependencies,
fetchPackageInfo,
@@ -497,42 +496,3 @@ async function asLockfileVersion(version: string) {
return version;
}
const yarnRcSchema = z.object({
plugins: z
.array(
z.object({
path: z.string(),
}),
)
.optional(),
});
async function getHasYarnPlugin() {
const yarnRcPath = paths.resolveTargetRoot('.yarnrc.yml');
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
if (e.code === 'ENOENT') {
// gracefully continue in case the file doesn't exist
return '';
}
throw e;
});
if (!yarnRcContent) {
return false;
}
const parseResult = yarnRcSchema.safeParse(yaml.parse(yarnRcContent));
if (!parseResult.success) {
throw new Error(
`Unexpected content in .yarnrc.yml: ${parseResult.error.toString()}`,
);
}
const yarnRc = parseResult.data;
return yarnRc.plugins?.some(
plugin => plugin.path === '.yarn/plugins/@yarnpkg/plugin-backstage.cjs',
);
}
@@ -27,6 +27,7 @@ import lowerFirst from 'lodash/lowerFirst';
import { Lockfile } from '../../../../lib/versioning';
import { paths } from '../../../../lib/paths';
import { createPackageVersionProvider } from '../../../../lib/version';
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
const builtInHelpers = {
camelCase,
@@ -53,7 +54,10 @@ export class PortableTemplater {
/* ignored */
}
const versionProvider = createPackageVersionProvider(lockfile);
const hasYarnPlugin = await getHasYarnPlugin();
const versionProvider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: hasYarnPlugin,
});
const templater = new PortableTemplater(
{