cli: add Backstage Yarn plugin support for templating
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user