Merge pull request #33001 from backstage/rugvip/cli-lockfile-consolidation

cli-node: consolidate Lockfile and versioning utilities
This commit is contained in:
Patrik Oldsberg
2026-02-25 14:51:59 +01:00
committed by GitHub
20 changed files with 124 additions and 280 deletions
+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.
+3 -1
View File
@@ -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
-3
View File
@@ -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);
}
}
-19
View File
@@ -1,19 +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.
*/
export { Lockfile } from './Lockfile';
export { fetchPackageInfo, mapDependencies } from './packages';
export type { YarnInfoInspectData } from './packages';
@@ -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';
@@ -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,
@@ -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';
+2 -3
View File
@@ -3272,6 +3272,8 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@manypkg/get-packages": "npm:^1.1.3"
"@types/yarnpkg__lockfile": "npm:^1.1.4"
"@yarnpkg/lockfile": "npm:^1.1.0"
"@yarnpkg/parsers": "npm:^3.0.0"
fs-extra: "npm:^11.2.0"
semver: "npm:^7.5.3"
@@ -3343,11 +3345,8 @@ __metadata:
"@types/terser-webpack-plugin": "npm:^5.0.4"
"@types/webpack-env": "npm:^1.15.2"
"@types/webpack-sources": "npm:^3.2.3"
"@types/yarnpkg__lockfile": "npm:^1.1.4"
"@typescript-eslint/eslint-plugin": "npm:^8.17.0"
"@typescript-eslint/parser": "npm:^8.16.0"
"@yarnpkg/lockfile": "npm:^1.1.0"
"@yarnpkg/parsers": "npm:^3.0.0"
bfj: "npm:^9.0.2"
buffer: "npm:^6.0.3"
chalk: "npm:^4.0.0"