Merge pull request #33000 from backstage/rugvip/move-cli-utils-to-cli-node

cli-node, cli: move yarnPlugin and SuccessCache to cli-node
This commit is contained in:
Patrik Oldsberg
2026-02-25 20:50:28 +01:00
committed by GitHub
14 changed files with 123 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-node': patch
---
Added `hasBackstageYarnPlugin` and `SuccessCache` exports, moved from `@backstage/cli`.
+1
View File
@@ -39,6 +39,7 @@
"@yarnpkg/parsers": "^3.0.0",
"fs-extra": "^11.2.0",
"semver": "^7.5.3",
"yaml": "^2.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
+14
View File
@@ -99,6 +99,9 @@ export class GitUtils {
static readFileAtRef(path: string, ref: string): Promise<string>;
}
// @public
export function hasBackstageYarnPlugin(workspaceDir?: string): Promise<boolean>;
// @public
export function isMonoRepo(): Promise<boolean>;
@@ -221,6 +224,17 @@ export function runWorkerQueueThreads<TItem, TResult, TContext>(
results: TResult[];
}>;
// @public
export class SuccessCache {
// (undocumented)
static create(options: { name: string; basePath?: string }): SuccessCache;
// (undocumented)
read(): Promise<Set<string>>;
static trimPaths(input: string): string;
// (undocumented)
write(newEntries: Iterable<string>): Promise<void>;
}
// @public
export type WorkerQueueThreadsOptions<TItem, TResult, TContext> = {
items: Iterable<TItem>;
@@ -22,6 +22,12 @@ const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli';
const CACHE_MAX_AGE_MS = 7 * 24 * 3600_000;
/**
* A file-system-based cache that tracks successful operations by storing
* timestamped marker files.
*
* @public
*/
export class SuccessCache {
readonly #path: string;
@@ -34,8 +40,15 @@ export class SuccessCache {
return input.replaceAll(targetPaths.rootDir, '');
}
constructor(name: string, basePath?: string) {
this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name);
static create(options: { name: string; basePath?: string }): SuccessCache {
return new SuccessCache(options);
}
private constructor(options: { name: string; basePath?: string }) {
this.#path = resolvePath(
options.basePath ?? DEFAULT_CACHE_BASE_PATH,
options.name,
);
}
async read(): Promise<Set<string>> {
@@ -89,7 +102,6 @@ export class SuccessCache {
const empty = Buffer.alloc(0);
for (const key of newEntries) {
// Remove any existing items with the key we're about to add
const trimmedItems = existingItems.filter(item =>
item.endsWith(`_${key}`),
);
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2024 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.
*/
export { SuccessCache } from './SuccessCache';
+3 -1
View File
@@ -20,7 +20,9 @@
* @packageDocumentation
*/
export * from './cache';
export * from './concurrency';
export * from './git';
export * from './monorepo';
export * from './concurrency';
export * from './roles';
export * from './yarn';
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { hasBackstageYarnPlugin } from './yarnPlugin';
@@ -16,12 +16,12 @@
import { createMockDirectory } from '@backstage/backend-test-utils';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import { getHasYarnPlugin } from './yarnPlugin';
import { hasBackstageYarnPlugin } from './yarnPlugin';
const mockDir = createMockDirectory();
overrideTargetPaths(mockDir.path);
describe('getHasYarnPlugin', () => {
describe('hasBackstageYarnPlugin', () => {
beforeEach(() => {
mockDir.clear();
});
@@ -29,7 +29,7 @@ describe('getHasYarnPlugin', () => {
it('should return false when .yarnrc.yml does not exist', async () => {
mockDir.setContent({});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(false);
});
@@ -38,7 +38,7 @@ describe('getHasYarnPlugin', () => {
'.yarnrc.yml': '',
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(false);
});
@@ -47,7 +47,7 @@ describe('getHasYarnPlugin', () => {
'.yarnrc.yml': 'plugins: []',
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(false);
});
@@ -60,7 +60,7 @@ plugins:
`,
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(false);
});
@@ -74,7 +74,7 @@ plugins:
`,
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(true);
});
@@ -86,7 +86,7 @@ plugins:
`,
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin();
expect(result).toBe(true);
});
@@ -95,7 +95,7 @@ plugins:
'.yarnrc.yml': 'invalid: yaml: content: [',
});
await expect(getHasYarnPlugin()).rejects.toThrow();
await expect(hasBackstageYarnPlugin()).rejects.toThrow();
});
it('should throw error when .yarnrc.yml has unexpected structure', async () => {
@@ -105,21 +105,22 @@ plugins: "not an array"
`,
});
await expect(getHasYarnPlugin()).rejects.toThrow(
await expect(hasBackstageYarnPlugin()).rejects.toThrow(
'Unexpected content in .yarnrc.yml',
);
});
it('should handle plugins with different structure', async () => {
it('should resolve from a custom workspace directory', async () => {
mockDir.setContent({
'.yarnrc.yml': `
'custom-dir': {
'.yarnrc.yml': `
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
`,
},
});
const result = await getHasYarnPlugin();
const result = await hasBackstageYarnPlugin(mockDir.resolve('custom-dir'));
expect(result).toBe(true);
});
});
@@ -15,6 +15,7 @@
*/
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import yaml from 'yaml';
import z from 'zod';
import { targetPaths } from '@backstage/cli-common';
@@ -30,15 +31,21 @@ const yarnRcSchema = z.object({
});
/**
* Detects whether the Backstage Yarn plugin is installed in the target repository.
* Detects whether the Backstage Yarn plugin is installed in the given workspace directory.
*
* @returns Promise<boolean> - true if the plugin is installed, false otherwise
* @param workspaceDir - The workspace root directory to check. Defaults to the target root.
* @returns Promise resolving to true if the plugin is installed, false otherwise
* @public
*/
export async function getHasYarnPlugin(): Promise<boolean> {
const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml');
export async function hasBackstageYarnPlugin(
workspaceDir?: string,
): Promise<boolean> {
const yarnRcPath = resolvePath(
workspaceDir ?? targetPaths.rootDir,
'.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;
@@ -24,11 +24,11 @@ import {
BackstagePackageJson,
Lockfile,
runWorkerQueueThreads,
SuccessCache,
} from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
function depCount(pkg: BackstagePackageJson) {
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
@@ -41,7 +41,10 @@ function depCount(pkg: BackstagePackageJson) {
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
const cache = new SuccessCache('lint', opts.successCacheDir);
const cache = SuccessCache.create({
name: 'lint',
basePath: opts.successCacheDir,
});
const cacheContext = opts.successCache
? {
entries: await cache.read(),
@@ -30,8 +30,11 @@ import { OptionValues } from 'commander';
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 {
hasBackstageYarnPlugin,
Lockfile,
runConcurrentTasks,
} from '@backstage/cli-node';
import {
fetchPackageInfo,
mapDependencies,
@@ -73,7 +76,7 @@ function extendsDefaultPattern(pattern: string): boolean {
export default async (opts: OptionValues) => {
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const hasYarnPlugin = await getHasYarnPlugin();
const yarnPluginEnabled = await hasBackstageYarnPlugin();
let pattern = opts.pattern;
@@ -127,7 +130,7 @@ export default async (opts: OptionValues) => {
});
}
if (hasYarnPlugin) {
if (yarnPluginEnabled) {
console.log();
console.log(
`Updating yarn plugin to v${releaseManifest.releaseVersion}...`,
@@ -211,7 +214,7 @@ export default async (opts: OptionValues) => {
const oldLockfileRange = await asLockfileVersion(oldRange);
const useBackstageRange =
hasYarnPlugin &&
yarnPluginEnabled &&
// Only use backstage:^ versions if the package is present in
// the manifest for the release we're bumping to.
releaseManifest.packages.find(
@@ -251,7 +254,7 @@ export default async (opts: OptionValues) => {
if (extendsDefaultPattern(pattern)) {
await bumpBackstageJsonVersion(
releaseManifest.releaseVersion,
hasYarnPlugin,
yarnPluginEnabled,
);
} else {
console.log(
@@ -316,7 +319,7 @@ export default async (opts: OptionValues) => {
console.log();
}
if (hasYarnPlugin) {
if (yarnPluginEnabled) {
console.log();
console.log(
chalk.blue(
@@ -28,7 +28,7 @@ import { Lockfile } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { createPackageVersionProvider } from '../../../../lib/version';
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
import { hasBackstageYarnPlugin } from '@backstage/cli-node';
const builtInHelpers = {
camelCase,
@@ -55,9 +55,9 @@ export class PortableTemplater {
/* ignored */
}
const hasYarnPlugin = await getHasYarnPlugin();
const yarnPluginEnabled = await hasBackstageYarnPlugin();
const versionProvider = createPackageVersionProvider(lockfile, {
preferBackstageProtocol: hasYarnPlugin,
preferBackstageProtocol: yarnPluginEnabled,
});
const templater = new PortableTemplater(
@@ -22,7 +22,7 @@ import yargs from 'yargs';
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
import { relative as relativePath } from 'node:path';
import { Command, OptionValues } from 'commander';
import { Lockfile, PackageGraph } from '@backstage/cli-node';
import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node';
import {
runCheck,
@@ -31,7 +31,6 @@ import {
findOwnPaths,
isChildPath,
} from '@backstage/cli-common';
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
type JestProject = {
displayName: string;
@@ -333,7 +332,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
);
}
const cache = new SuccessCache('test', opts.successCacheDir);
const cache = SuccessCache.create({
name: 'test',
basePath: opts.successCacheDir,
});
const graph = await getPackageGraph();
// Shared state for the bridge
+1
View File
@@ -3277,6 +3277,7 @@ __metadata:
"@yarnpkg/parsers": "npm:^3.0.0"
fs-extra: "npm:^11.2.0"
semver: "npm:^7.5.3"
yaml: "npm:^2.0.0"
zod: "npm:^3.25.76"
languageName: unknown
linkType: soft