From d14ef24882b25e9c35c34a8076acc54ef38bcf57 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 1 Oct 2025 15:09:57 +0200 Subject: [PATCH] cli: add Backstage Yarn plugin support for templating Signed-off-by: Patrik Oldsberg --- .changeset/yarn-plugin-integration.md | 5 + docs/tooling/cli/04-templates.md | 10 ++ packages/cli/src/lib/version.test.ts | 106 ++++++++++++++ packages/cli/src/lib/version.ts | 32 +++-- packages/cli/src/lib/yarnPlugin.test.ts | 131 ++++++++++++++++++ packages/cli/src/lib/yarnPlugin.ts | 66 +++++++++ .../modules/migrate/commands/versions/bump.ts | 42 +----- .../new/lib/execution/PortableTemplater.ts | 6 +- 8 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 .changeset/yarn-plugin-integration.md create mode 100644 packages/cli/src/lib/yarnPlugin.test.ts create mode 100644 packages/cli/src/lib/yarnPlugin.ts diff --git a/.changeset/yarn-plugin-integration.md b/.changeset/yarn-plugin-integration.md new file mode 100644 index 0000000000..2847e89273 --- /dev/null +++ b/.changeset/yarn-plugin-integration.md @@ -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. diff --git a/docs/tooling/cli/04-templates.md b/docs/tooling/cli/04-templates.md index c3bfb57d11..f634894c4f 100644 --- a/docs/tooling/cli/04-templates.md +++ b/docs/tooling/cli/04-templates.md @@ -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. diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/lib/version.test.ts index 744874d7e9..4cb7591faf 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/lib/version.test.ts @@ -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}`, + ); + }); + }); }); diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index 5a2a8c7838..a7a7ca1bdb 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -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), ); diff --git a/packages/cli/src/lib/yarnPlugin.test.ts b/packages/cli/src/lib/yarnPlugin.test.ts new file mode 100644 index 0000000000..d5c865e1c4 --- /dev/null +++ b/packages/cli/src/lib/yarnPlugin.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/lib/yarnPlugin.ts b/packages/cli/src/lib/yarnPlugin.ts new file mode 100644 index 0000000000..5ad49b2524 --- /dev/null +++ b/packages/cli/src/lib/yarnPlugin.ts @@ -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 - true if the plugin is installed, false otherwise + */ +export async function getHasYarnPlugin(): Promise { + 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); +} diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 391ccf8f22..39cfeddbd5 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -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', - ); -} diff --git a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts index 519f7c6dbc..9dfd60baba 100644 --- a/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts +++ b/packages/cli/src/modules/new/lib/execution/PortableTemplater.ts @@ -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( {