Merge remote-tracking branch 'origin/master' into package-workspaces
This commit is contained in:
@@ -35,14 +35,17 @@
|
||||
"@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",
|
||||
"yaml": "^2.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -111,6 +114,7 @@ export class Lockfile {
|
||||
keys(): IterableIterator<string>;
|
||||
static load(path: string): Promise<Lockfile>;
|
||||
static parse(content: string): Lockfile;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -220,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>;
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Trim any occurrences of the workspace root path from the input string. This
|
||||
* is useful to ensure stable hashes that don't vary based on the workspace
|
||||
* location.
|
||||
*/
|
||||
static trimPaths(input: string) {
|
||||
return input.replaceAll(targetPaths.rootDir, '');
|
||||
}
|
||||
|
||||
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>> {
|
||||
try {
|
||||
const stat = await fs.stat(this.#path);
|
||||
if (!stat.isDirectory()) {
|
||||
await fs.rm(this.#path);
|
||||
return new Set();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return new Set();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const items = await fs.readdir(this.#path);
|
||||
|
||||
const returned = new Set<string>();
|
||||
const removed = new Set<string>();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const item of items) {
|
||||
const split = item.split('_');
|
||||
if (split.length !== 2) {
|
||||
removed.add(item);
|
||||
continue;
|
||||
}
|
||||
const createdAt = parseInt(split[0], 10);
|
||||
if (Number.isNaN(createdAt) || now - createdAt > CACHE_MAX_AGE_MS) {
|
||||
removed.add(item);
|
||||
} else {
|
||||
returned.add(split[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of removed) {
|
||||
await fs.unlink(resolvePath(this.#path, item));
|
||||
}
|
||||
|
||||
return returned;
|
||||
}
|
||||
|
||||
async write(newEntries: Iterable<string>): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
await fs.ensureDir(this.#path);
|
||||
|
||||
const existingItems = await fs.readdir(this.#path);
|
||||
|
||||
const empty = Buffer.alloc(0);
|
||||
for (const key of newEntries) {
|
||||
const trimmedItems = existingItems.filter(item =>
|
||||
item.endsWith(`_${key}`),
|
||||
);
|
||||
for (const trimmedItem of trimmedItems) {
|
||||
await fs.unlink(resolvePath(this.#path, trimmedItem));
|
||||
}
|
||||
|
||||
await fs.writeFile(resolvePath(this.#path, `${now}_${key}`), empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import { hasBackstageYarnPlugin } from './yarnPlugin';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('hasBackstageYarnPlugin', () => {
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
|
||||
it('should return false when .yarnrc.yml does not exist', async () => {
|
||||
mockDir.setContent({});
|
||||
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when .yarnrc.yml is empty', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': '',
|
||||
});
|
||||
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugins array is empty', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': 'plugins: []',
|
||||
});
|
||||
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
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 hasBackstageYarnPlugin();
|
||||
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 hasBackstageYarnPlugin();
|
||||
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 hasBackstageYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when .yarnrc.yml has invalid content', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': 'invalid: yaml: content: [',
|
||||
});
|
||||
|
||||
await expect(hasBackstageYarnPlugin()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when .yarnrc.yml has unexpected structure', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins: "not an array"
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(hasBackstageYarnPlugin()).rejects.toThrow(
|
||||
'Unexpected content in .yarnrc.yml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve from a custom workspace directory', async () => {
|
||||
mockDir.setContent({
|
||||
'custom-dir': {
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await hasBackstageYarnPlugin(mockDir.resolve('custom-dir'));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 { resolve as resolvePath } from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
import z from 'zod';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const yarnRcSchema = z.object({
|
||||
plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Detects whether the Backstage Yarn plugin is installed in the given workspace directory.
|
||||
*
|
||||
* @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 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') {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user