cli: forklift monorepo utils to cli-node

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-04-06 11:12:42 +02:00
parent e318b75754
commit c9321bea99
16 changed files with 22 additions and 2 deletions
@@ -0,0 +1,236 @@
/*
* 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 { resolve as resolvePath } from 'path';
import { getPackages } from '@manypkg/get-packages';
import { PackageGraph } from './PackageGraph';
import { Lockfile } from '../versioning/Lockfile';
import { listChangedFiles, readFileAtRef } from '../git';
jest.mock('../git');
const mockListChangedFiles = listChangedFiles as jest.MockedFunction<
typeof listChangedFiles
>;
const mockReadFileAtRef = readFileAtRef as jest.MockedFunction<
typeof readFileAtRef
>;
jest.mock('../paths', () => ({
paths: {
targetRoot: '/',
resolveTargetRoot: (...paths: string[]) => resolvePath('/', ...paths),
},
}));
const testPackages = [
{
dir: '/packages/a',
packageJson: {
name: 'a',
version: '1.0.0',
dependencies: {
b: '1.0.0',
},
devDependencies: {
c: '1.0.0',
},
},
},
{
dir: '/packages/b',
packageJson: {
name: 'b',
version: '1.0.0',
devDependencies: {
c: '1.0.0',
},
},
},
{
dir: '/packages/c',
packageJson: {
name: 'c',
version: '1.0.0',
},
},
];
describe('PackageGraph', () => {
it('is able to construct a graph from this repo', async () => {
const { packages } = await getPackages(__dirname);
const graph = PackageGraph.fromPackages(packages);
expect(graph.has('@backstage/cli')).toBe(true);
});
it('creates a graph', () => {
const graph = PackageGraph.fromPackages(testPackages);
const a = graph.get('a');
const b = graph.get('b');
const c = graph.get('c');
expect(a).toMatchObject({
name: 'a',
dir: '/packages/a',
allLocalDependencies: new Map([
['b', b],
['c', c],
]),
publishedLocalDependencies: new Map([['b', b]]),
localDependencies: new Map([['b', b]]),
localDevDependencies: new Map([['c', c]]),
localOptionalDependencies: new Map(),
allLocalDependents: new Map(),
publishedLocalDependents: new Map(),
localDependents: new Map(),
localDevDependents: new Map(),
localOptionalDependents: new Map(),
});
expect(b).toMatchObject({
name: 'b',
dir: '/packages/b',
allLocalDependencies: new Map([['c', c]]),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map([['c', c]]),
localOptionalDependencies: new Map(),
allLocalDependents: new Map([['a', a]]),
publishedLocalDependents: new Map([['a', a]]),
localDependents: new Map([['a', a]]),
localDevDependents: new Map(),
localOptionalDependents: new Map(),
});
expect(c).toMatchObject({
name: 'c',
dir: '/packages/c',
allLocalDependencies: new Map(),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map(),
localOptionalDependencies: new Map(),
allLocalDependents: new Map([
['a', a],
['b', b],
]),
publishedLocalDependents: new Map(),
localDependents: new Map(),
localDevDependents: new Map([
['a', a],
['b', b],
]),
localOptionalDependents: new Map(),
});
});
it('collects package names', () => {
const graph = PackageGraph.fromPackages(testPackages);
expect(graph.collectPackageNames(['a'], () => undefined)).toEqual(
new Set(['a']),
);
expect(
graph.collectPackageNames(['a'], pkg => pkg.localDependencies.keys()),
).toEqual(new Set(['a', 'b']));
expect(
graph.collectPackageNames(['a'], pkg => pkg.localDevDependencies.keys()),
).toEqual(new Set(['a', 'c']));
expect(
graph.collectPackageNames(['b', 'a'], pkg =>
pkg.localDevDependencies.keys(),
),
).toEqual(new Set(['b', 'a', 'c']));
// Should not get stuck in cycles
expect(graph.collectPackageNames(['a'], () => ['a', 'b', 'c'])).toEqual(
new Set(['b', 'a', 'c']),
);
// Throws on unknown packages
expect(() => graph.collectPackageNames(['a'], () => ['unknown'])).toThrow(
`Package 'unknown' not found`,
);
});
it('lists changed packages', async () => {
const graph = PackageGraph.fromPackages(testPackages);
mockListChangedFiles.mockResolvedValueOnce(
[
'README.md',
'packages/a/src/foo.ts',
'packages/a/src/bar.ts',
'packages/b/package.json',
'packages/f/src/foo.ts',
'packages/f/README.md',
'plugins/foo/src/index.ts',
].sort(),
);
await expect(
graph.listChangedPackages({ ref: 'origin/master' }),
).resolves.toEqual([graph.get('a'), graph.get('b')]);
});
it('lists changed packages with lockfile analysis', async () => {
const graph = PackageGraph.fromPackages(testPackages);
mockListChangedFiles.mockResolvedValueOnce(
['README.md', 'packages/a/src/foo.ts', 'yarn.lock'].sort(),
);
mockReadFileAtRef.mockResolvedValueOnce(`
a@^1:
version: "1.0.0"
c@^1:
version: "1.0.0"
dependencies:
c-dep: ^1
c-dep@^2:
version: "2.0.0"
integrity: sha512-xyz
`);
jest.spyOn(Lockfile, 'load').mockResolvedValueOnce(
Lockfile.parse(`
a@^1:
version: "1.0.0"
c@^1:
version: "1.0.0"
dependencies:
c-dep: ^1
c-dep@^2:
version: "2.0.0"
integrity: sha512-xyz-other
`),
);
await expect(
graph
.listChangedPackages({
ref: 'origin/master',
analyzeLockfile: true,
})
.then(pkgs => pkgs.map(pkg => pkg.name)),
).resolves.toEqual(['a', 'c']);
expect(mockReadFileAtRef).toHaveBeenCalledWith(
'yarn.lock',
'origin/master',
);
});
});
@@ -0,0 +1,332 @@
/*
* 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 path from 'path';
import { getPackages, Package } from '@manypkg/get-packages';
import { paths } from '../paths';
import { PackageRole } from '../role';
import { listChangedFiles, readFileAtRef } from '../git';
import { Lockfile } from '../versioning';
import { JsonValue } from '@backstage/types';
type PackageJSON = Package['packageJson'];
export interface ExtendedPackageJSON extends PackageJSON {
main?: string;
module?: string;
types?: string;
scripts?: {
[key: string]: string;
};
// The `bundled` field is a field known within Backstage, it means
// that the package bundles all of its dependencies in its build output.
bundled?: boolean;
backstage?: {
role?: PackageRole;
};
exports?: JsonValue;
typesVersions?: Record<string, Record<string, string[]>>;
files?: string[];
publishConfig?: {
access?: 'public' | 'restricted';
directory?: string;
registry?: string;
alphaTypes?: string;
betaTypes?: string;
};
}
export type ExtendedPackage = {
dir: string;
packageJson: ExtendedPackageJSON;
};
export type PackageGraphNode = {
/** The name of the package */
name: string;
/** The directory of the package */
dir: string;
/** The package data of the package itself */
packageJson: ExtendedPackageJSON;
/** All direct local dependencies of the package */
allLocalDependencies: Map<string, PackageGraphNode>;
/** All direct local dependencies that will be present in the published package */
publishedLocalDependencies: Map<string, PackageGraphNode>;
/** Local dependencies */
localDependencies: Map<string, PackageGraphNode>;
/** Local devDependencies */
localDevDependencies: Map<string, PackageGraphNode>;
/** Local optionalDependencies */
localOptionalDependencies: Map<string, PackageGraphNode>;
/** All direct incoming local dependencies of the package */
allLocalDependents: Map<string, PackageGraphNode>;
/** All direct incoming local dependencies that will be present in the published package */
publishedLocalDependents: Map<string, PackageGraphNode>;
/** Incoming local dependencies */
localDependents: Map<string, PackageGraphNode>;
/** Incoming local devDependencies */
localDevDependents: Map<string, PackageGraphNode>;
/** Incoming local optionalDependencies */
localOptionalDependents: Map<string, PackageGraphNode>;
};
export class PackageGraph extends Map<string, PackageGraphNode> {
static async listTargetPackages(): Promise<ExtendedPackage[]> {
const { packages } = await getPackages(paths.targetDir);
return packages as ExtendedPackage[];
}
static fromPackages(packages: Package[]): PackageGraph {
const graph = new PackageGraph();
// Add all local packages to the graph
for (const pkg of packages) {
const name = pkg.packageJson.name;
const existingPkg = graph.get(name);
if (existingPkg) {
throw new Error(
`Duplicate package name '${name}' at ${pkg.dir} and ${existingPkg.dir}`,
);
}
graph.set(name, {
name,
dir: pkg.dir,
packageJson: pkg.packageJson as ExtendedPackageJSON,
allLocalDependencies: new Map(),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map(),
localOptionalDependencies: new Map(),
allLocalDependents: new Map(),
publishedLocalDependents: new Map(),
localDependents: new Map(),
localDevDependents: new Map(),
localOptionalDependents: new Map(),
});
}
// Populate the local dependency structure
for (const node of graph.values()) {
for (const depName of Object.keys(node.packageJson.dependencies || {})) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.publishedLocalDependencies.set(depName, depPkg);
node.localDependencies.set(depName, depPkg);
depPkg.allLocalDependents.set(node.name, node);
depPkg.publishedLocalDependents.set(node.name, node);
depPkg.localDependents.set(node.name, node);
}
}
for (const depName of Object.keys(
node.packageJson.devDependencies || {},
)) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.localDevDependencies.set(depName, depPkg);
depPkg.allLocalDependents.set(node.name, node);
depPkg.localDevDependents.set(node.name, node);
}
}
for (const depName of Object.keys(
node.packageJson.optionalDependencies || {},
)) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.publishedLocalDependencies.set(depName, depPkg);
node.localOptionalDependencies.set(depName, depPkg);
depPkg.allLocalDependents.set(node.name, node);
depPkg.publishedLocalDependents.set(node.name, node);
depPkg.localOptionalDependents.set(node.name, node);
}
}
}
return graph;
}
/**
* Traverses the package graph and collects a set of package names.
*
* The traversal starts at the provided list names, and continues
* throughout all the names returned by the `collectFn`, which is
* called once for each seen package.
*/
collectPackageNames(
startingPackageNames: string[],
collectFn: (pkg: PackageGraphNode) => Iterable<string> | undefined,
): Set<string> {
const targets = new Set<string>();
const searchNames = startingPackageNames.slice();
while (searchNames.length) {
const name = searchNames.pop()!;
if (targets.has(name)) {
continue;
}
const node = this.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
targets.add(name);
const collected = collectFn(node);
if (collected) {
searchNames.push(...collected);
}
}
return targets;
}
async listChangedPackages(options: {
ref: string;
analyzeLockfile?: boolean;
}) {
const changedFiles = await listChangedFiles(options.ref);
const dirMap = new Map(
Array.from(this.values()).map(pkg => [
// relative from root, convert to posix, and add a / at the end
path
.relative(paths.targetRoot, pkg.dir)
.split(path.sep)
.join(path.posix.sep) + path.posix.sep,
pkg,
]),
);
const packageDirs = Array.from(dirMap.keys());
const result = new Array<PackageGraphNode>();
let searchIndex = 0;
changedFiles.sort();
packageDirs.sort();
for (const packageDir of packageDirs) {
// Skip through changes that appear before our package dir
while (
searchIndex < changedFiles.length &&
changedFiles[searchIndex] < packageDir
) {
searchIndex += 1;
}
// Check if we arrived at a match, otherwise we move on to the next package dir
if (changedFiles[searchIndex]?.startsWith(packageDir)) {
searchIndex += 1;
result.push(dirMap.get(packageDir)!);
// Skip through the rest of the changed files for the same package
while (changedFiles[searchIndex]?.startsWith(packageDir)) {
searchIndex += 1;
}
}
}
if (changedFiles.includes('yarn.lock') && options.analyzeLockfile) {
// Load the lockfile in the working tree and the one at the ref and diff them
let thisLockfile: Lockfile;
let otherLockfile: Lockfile;
try {
thisLockfile = await Lockfile.load(
paths.resolveTargetRoot('yarn.lock'),
);
otherLockfile = Lockfile.parse(
await readFileAtRef('yarn.lock', options.ref),
);
} catch (error) {
console.warn(
`Failed to read lockfiles, assuming all packages have changed, ${error}`,
);
return Array.from(this.values());
}
const diff = thisLockfile.diff(otherLockfile);
// Create a simplified dependency graph only keeps track of package names
const graph = thisLockfile.createSimplifiedDependencyGraph();
// Merge the dependency graph from the other lockfile into this one in
// order to be able to detect removals accurately.
{
const otherGraph = thisLockfile.createSimplifiedDependencyGraph();
for (const [name, dependencies] of otherGraph) {
const node = graph.get(name);
if (node) {
dependencies.forEach(d => node.add(d));
} else {
graph.set(name, dependencies);
}
}
}
// The check is simplified by only considering the package names rather
// than the exact version range queries that were changed.
// TODO(Rugvip): Use a more exact check
const changedPackages = new Set(
[...diff.added, ...diff.changed, ...diff.removed].map(e => e.name),
);
// Starting with our set of changed packages from the diff, we loop through
// the full graph and add any package that has a dependency on a changed package.
// We keep looping until all transitive dependencies have been detected.
let changed = false;
do {
changed = false;
for (const [name, dependencies] of graph) {
if (changedPackages.has(name)) {
continue;
}
for (const dep of dependencies) {
if (changedPackages.has(dep)) {
changed = true;
changedPackages.add(name);
break;
}
}
}
} while (changed);
// Add all local packages that had a transitive dependency change to the result set
for (const node of this.values()) {
if (changedPackages.has(node.name) && !result.includes(node)) {
result.push(node);
}
}
}
return result;
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2023 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 { extname } from 'path';
import { ExtendedPackageJSON } from './PackageGraph';
export interface EntryPoint {
mount: string;
path: string;
name: string;
ext: string;
}
// Unless explicitly specified in exports, the index entrypoint is always
// assumed to be at src/index.ts for backwards compatibility.
const defaultIndex = {
mount: '.',
path: 'src/index.ts',
name: 'index',
ext: '.ts',
};
function parseEntryPoint(mount: string, path: string): EntryPoint {
let name = mount;
if (name === '.') {
name = 'index';
} else if (name.startsWith('./')) {
name = name.slice(2);
}
if (name.includes('/')) {
throw new Error(`Mount point '${mount}' may not contain multiple slashes`);
}
return { mount, path, name, ext: extname(path) };
}
export function readEntryPoints(pkg: ExtendedPackageJSON): Array<EntryPoint> {
const exp = pkg.exports;
if (typeof exp === 'string') {
return [defaultIndex];
} else if (exp && typeof exp === 'object' && !Array.isArray(exp)) {
const entryPoints = new Array<{
mount: string;
path: string;
name: string;
ext: string;
}>();
for (const mount of Object.keys(exp)) {
const path = exp[mount];
if (typeof path !== 'string') {
throw new Error(
`Exports field value must be a string, got '${JSON.stringify(path)}'`,
);
}
entryPoints.push(parseEntryPoint(mount, path));
}
return entryPoints;
}
return [defaultIndex];
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2022 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 { PackageGraph } from './PackageGraph';
export type {
PackageGraphNode,
ExtendedPackage,
ExtendedPackageJSON,
} from './PackageGraph';
@@ -0,0 +1,28 @@
/*
* Copyright 2022 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 { paths } from '../paths';
import fs from 'fs-extra';
export async function isMonoRepo(): Promise<boolean> {
const rootPackageJsonPath = paths.resolveTargetRoot('package.json');
try {
const pkg = await fs.readJson(rootPackageJsonPath);
return Boolean(pkg?.workspaces?.packages);
} catch (error) {
return false;
}
}
@@ -0,0 +1,50 @@
/*
* Copyright 2022 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 { isMonoRepo } from './isMonoRepo';
import mockFs from 'mock-fs';
describe('isMonoRepo', () => {
afterEach(() => {
mockFs.restore();
});
it('should detect a monorepo', async () => {
mockFs({
'package.json': JSON.stringify({
name: 'foo',
workspaces: {
packages: ['packages/*'],
},
}),
});
await expect(isMonoRepo()).resolves.toBe(true);
});
it('should detect a non- monorepo', async () => {
mockFs({
'package.json': JSON.stringify({
name: 'foo',
}),
});
await expect(isMonoRepo()).resolves.toBe(false);
});
it('should return false if package.json is missing', async () => {
mockFs({});
await expect(isMonoRepo()).resolves.toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { findPaths } from '@backstage/cli-common';
/* eslint-disable-next-line no-restricted-syntax */
export const paths = findPaths(__dirname);
@@ -0,0 +1,328 @@
/*
* Copyright 2022 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 mockFs from 'mock-fs';
import { Command } from 'commander';
import {
getRoleInfo,
getRoleFromPackage,
findRoleFromCommand,
detectRoleFromPackage,
} from './PackageRoles';
describe('getRoleInfo', () => {
it('provides role info by role', () => {
expect(getRoleInfo('web-library')).toEqual({
role: 'web-library',
platform: 'web',
output: ['types', 'esm'],
});
expect(getRoleInfo('frontend')).toEqual({
role: 'frontend',
platform: 'web',
output: ['bundle'],
});
expect(() => getRoleInfo('invalid')).toThrow(
`Unknown package role 'invalid'`,
);
});
});
describe('getRoleFromPackage', () => {
it('reads explicit package roles', () => {
expect(
getRoleFromPackage({
backstage: {
role: 'web-library',
},
}),
).toEqual('web-library');
expect(
getRoleFromPackage({
backstage: {
role: 'frontend',
},
}),
).toEqual('frontend');
expect(() =>
getRoleFromPackage({
name: 'test',
backstage: {},
}),
).toThrow('Package test must specify a role in the "backstage" field');
expect(() =>
getRoleFromPackage({
name: 'test',
backstage: { role: 'invalid' },
}),
).toThrow(`Unknown package role 'invalid'`);
});
});
describe('findRoleFromCommand', () => {
function mkCommand(args: string) {
const parsed = new Command()
.option('--role <role>', 'test role')
.parse(['node', 'entry.js', ...args.split(' ')]) as Command;
return parsed.opts();
}
beforeEach(() => {
mockFs({
'package.json': JSON.stringify({
name: 'test',
backstage: {
role: 'web-library',
},
}),
});
});
afterEach(() => {
mockFs.restore();
});
it('provides role info by role', async () => {
await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual(
'web-library',
);
await expect(
findRoleFromCommand(mkCommand('--role node-library')),
).resolves.toEqual('node-library');
await expect(
findRoleFromCommand(mkCommand('--role invalid')),
).rejects.toThrow(`Unknown package role 'invalid'`);
});
});
describe('detectRoleFromPackage', () => {
it('detects the role of example-app', () => {
expect(
detectRoleFromPackage({
name: 'example-app',
private: true,
bundled: true,
scripts: {
start: 'backstage-cli app:serve',
build: 'backstage-cli app:build',
clean: 'backstage-cli clean',
test: 'backstage-cli test',
'test:e2e':
'start-server-and-test start http://localhost:3000 cy:dev',
'test:e2e:ci':
'start-server-and-test start http://localhost:3000 cy:run',
lint: 'backstage-cli lint',
'cy:dev': 'cypress open',
'cy:run': 'cypress run',
},
}),
).toEqual('frontend');
});
it('detects the role of example-backend', () => {
expect(
detectRoleFromPackage({
name: 'example-backend',
main: 'dist/index.cjs.js',
types: 'src/index.ts',
scripts: {
build: 'backstage-cli backend:bundle',
'build-image':
'docker build ../.. -f Dockerfile --tag example-backend',
start: 'backstage-cli backend:dev',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
clean: 'backstage-cli clean',
},
}),
).toEqual('backend');
});
it('detects the role of @backstage/plugin-catalog', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-catalog',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.esm.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli plugin:build',
start: 'backstage-cli plugin:serve',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
diff: 'backstage-cli plugin:diff',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('frontend-plugin');
});
it('detects the role of @backstage/plugin-catalog-backend', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-catalog-backend',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.cjs.js',
types: 'dist/index.d.ts',
},
scripts: {
start: 'backstage-cli backend:dev',
build: 'backstage-cli backend:build',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('backend-plugin');
});
it('detects the role of @backstage/plugin-catalog-react', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-catalog-react',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.esm.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli build',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('web-library');
});
it('detects the role of @backstage/plugin-catalog-common', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-catalog-common',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.cjs.js',
module: 'dist/index.esm.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli build',
lint: 'backstage-cli lint',
test: 'backstage-cli test --passWithNoTests',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('common-library');
});
it('detects the role of @backstage/plugin-catalog-backend-module-ldap', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-catalog-backend-module-ldap',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.cjs.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli backend:build',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('backend-plugin-module');
});
it('detects the role of @backstage/plugin-permission-node', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-permission-node',
main: 'src/index.ts',
types: 'src/index.ts',
homepage: 'https://backstage.io',
publishConfig: {
access: 'public',
main: 'dist/index.cjs.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli backend:build',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('node-library');
});
it('detects the role of @backstage/plugin-analytics-module-ga', () => {
expect(
detectRoleFromPackage({
name: '@backstage/plugin-analytics-module-ga',
main: 'src/index.ts',
types: 'src/index.ts',
publishConfig: {
access: 'public',
main: 'dist/index.esm.js',
types: 'dist/index.d.ts',
},
scripts: {
build: 'backstage-cli plugin:build',
start: 'backstage-cli plugin:serve',
lint: 'backstage-cli lint',
test: 'backstage-cli test',
diff: 'backstage-cli plugin:diff',
prepack: 'backstage-cli prepack',
postpack: 'backstage-cli postpack',
clean: 'backstage-cli clean',
},
}),
).toEqual('frontend-plugin-module');
});
});
+188
View File
@@ -0,0 +1,188 @@
/*
* Copyright 2022 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 { z } from 'zod';
import fs from 'fs-extra';
import { OptionValues } from 'commander';
import { paths } from '../paths';
import { PackageRole, PackageRoleInfo } from './types';
const packageRoleInfos: PackageRoleInfo[] = [
{
role: 'frontend',
platform: 'web',
output: ['bundle'],
},
{
role: 'backend',
platform: 'node',
output: ['bundle'],
},
{
role: 'cli',
platform: 'node',
output: ['cjs'],
},
{
role: 'web-library',
platform: 'web',
output: ['types', 'esm'],
},
{
role: 'node-library',
platform: 'node',
output: ['types', 'cjs'],
},
{
role: 'common-library',
platform: 'common',
output: ['types', 'esm', 'cjs'],
},
{
role: 'frontend-plugin',
platform: 'web',
output: ['types', 'esm'],
},
{
role: 'frontend-plugin-module',
platform: 'web',
output: ['types', 'esm'],
},
{
role: 'backend-plugin',
platform: 'node',
output: ['types', 'cjs'],
},
{
role: 'backend-plugin-module',
platform: 'node',
output: ['types', 'cjs'],
},
];
export function getRoleInfo(role: string): PackageRoleInfo {
const roleInfo = packageRoleInfos.find(r => r.role === role);
if (!roleInfo) {
throw new Error(`Unknown package role '${role}'`);
}
return roleInfo;
}
const readSchema = z.object({
name: z.string().optional(),
backstage: z
.object({
role: z.string().optional(),
})
.optional(),
});
export function getRoleFromPackage(pkgJson: unknown): PackageRole | undefined {
const pkg = readSchema.parse(pkgJson);
// If there's an explicit role, use that.
if (pkg.backstage) {
const { role } = pkg.backstage;
if (!role) {
throw new Error(
`Package ${pkg.name} must specify a role in the "backstage" field`,
);
}
return getRoleInfo(role).role;
}
return undefined;
}
export async function findRoleFromCommand(
opts: OptionValues,
): Promise<PackageRole> {
if (opts.role) {
return getRoleInfo(opts.role)?.role;
}
const pkg = await fs.readJson(paths.resolveTarget('package.json'));
const info = getRoleFromPackage(pkg);
if (!info) {
throw new Error(`Target package must have 'backstage.role' set`);
}
return info;
}
const detectionSchema = z.object({
name: z.string().optional(),
scripts: z
.object({
start: z.string().optional(),
build: z.string().optional(),
})
.optional(),
publishConfig: z
.object({
main: z.string().optional(),
types: z.string().optional(),
module: z.string().optional(),
})
.optional(),
main: z.string().optional(),
types: z.string().optional(),
module: z.string().optional(),
});
export function detectRoleFromPackage(
pkgJson: unknown,
): PackageRole | undefined {
const pkg = detectionSchema.parse(pkgJson);
if (pkg.scripts?.start?.includes('app:serve')) {
return 'frontend';
}
if (pkg.scripts?.build?.includes('backend:bundle')) {
return 'backend';
}
if (pkg.name?.includes('plugin-') && pkg.name?.includes('-backend-module-')) {
return 'backend-plugin-module';
}
if (pkg.name?.includes('plugin-') && pkg.name?.includes('-module-')) {
return 'frontend-plugin-module';
}
if (pkg.scripts?.start?.includes('plugin:serve')) {
return 'frontend-plugin';
}
if (pkg.scripts?.start?.includes('backend:dev')) {
return 'backend-plugin';
}
const mainEntry = pkg.publishConfig?.main || pkg.main;
const moduleEntry = pkg.publishConfig?.module || pkg.module;
const typesEntry = pkg.publishConfig?.types || pkg.types;
if (typesEntry) {
if (mainEntry && moduleEntry) {
return 'common-library';
}
if (moduleEntry || mainEntry?.endsWith('.esm.js')) {
return 'web-library';
}
if (mainEntry) {
return 'node-library';
}
} else if (mainEntry) {
return 'cli';
}
return undefined;
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2022 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 type {
PackageRoleInfo,
PackagePlatform,
PackageOutputType,
PackageRole,
} from './types';
export {
getRoleInfo,
getRoleFromPackage,
findRoleFromCommand,
detectRoleFromPackage,
} from './PackageRoles';
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright 2022 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 type PackageRole =
| 'frontend'
| 'backend'
| 'cli'
| 'web-library'
| 'node-library'
| 'common-library'
| 'frontend-plugin'
| 'frontend-plugin-module'
| 'backend-plugin'
| 'backend-plugin-module';
export type PackagePlatform = 'node' | 'web' | 'common';
export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs';
export interface PackageRoleInfo {
role: PackageRole;
platform: PackagePlatform;
output: PackageOutputType[];
}
@@ -0,0 +1,786 @@
/*
* 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 mockFs from 'mock-fs';
import { ExtendedPackage } from '../monorepo';
import { Lockfile } from './Lockfile';
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"
`;
const mockADedup = `${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, b@^2:
version "2.0.1"
`;
const mockB = `${LEGACY_HEADER}
"@s/a@*", "@s/a@1 || 2", "@s/a@^1":
version "1.0.1"
"@s/a@^2.0.x":
version "2.0.0"
`;
const mockBDedup = `${LEGACY_HEADER}
"@s/a@*", "@s/a@1 || 2", "@s/a@^2.0.x":
version "2.0.0"
"@s/a@^1":
version "1.0.1"
`;
describe('Lockfile', () => {
afterEach(() => {
mockFs.restore();
});
it('should load and serialize mockA', async () => {
mockFs({
'/yarn.lock': mockA,
});
const lockfile = await Lockfile.load('/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);
});
it('should deduplicate and save mockA', async () => {
mockFs({
'/yarn.lock': mockA,
});
const lockfile = await Lockfile.load('/yarn.lock');
const result = lockfile.analyze({ localPackages: new Map() });
expect(result).toEqual({
invalidRanges: [],
newRanges: [],
newVersions: [
{
name: 'b',
range: '^2',
oldVersion: '2.0.0',
newVersion: '2.0.1',
},
],
});
expect(lockfile.toString()).toBe(mockA);
lockfile.replaceVersions(result.newVersions);
expect(lockfile.toString()).toBe(mockADedup);
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA);
await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined();
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup);
});
it('should deduplicate mockB', async () => {
mockFs({
'/yarn.lock': mockB,
});
const lockfile = await Lockfile.load('/yarn.lock');
const result = lockfile.analyze({ localPackages: new Map() });
expect(result).toEqual({
invalidRanges: [],
newRanges: [
{
name: '@s/a',
oldRange: '^1',
newRange: '^2.0.x',
oldVersion: '1.0.1',
newVersion: '2.0.0',
},
],
newVersions: [
{
name: '@s/a',
range: '*',
oldVersion: '1.0.1',
newVersion: '2.0.0',
},
{
name: '@s/a',
range: '1 || 2',
oldVersion: '1.0.1',
newVersion: '2.0.0',
},
],
});
expect(lockfile.toString()).toBe(mockB);
lockfile.replaceVersions(result.newVersions);
expect(lockfile.toString()).toBe(mockBDedup);
});
});
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
`;
const mockANewDedup = `${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.1
`;
const mockANewLocal = `${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: 0.0.0-use.local
b@^2:
version: 2.0.0
`;
const mockANewLocalDedup = `${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: 0.0.0-use.local
b@^2:
version: 0.0.0-use.local
`;
describe('New Lockfile', () => {
afterEach(() => {
mockFs.restore();
});
it('should load and serialize mockANew', async () => {
mockFs({
'/yarn.lock': mockANew,
});
const lockfile = await Lockfile.load('/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);
});
it('should deduplicate and save mockANew', async () => {
mockFs({
'/yarn.lock': mockANew,
});
const lockfile = await Lockfile.load('/yarn.lock');
const result = lockfile.analyze({ localPackages: new Map() });
expect(result).toEqual({
invalidRanges: [],
newRanges: [],
newVersions: [
{
name: 'b',
range: '^2',
oldVersion: '2.0.0',
newVersion: '2.0.1',
},
],
});
expect(lockfile.toString()).toBe(mockANew);
lockfile.replaceVersions(result.newVersions);
expect(lockfile.toString()).toBe(mockANewDedup);
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockANew);
await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined();
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(
mockANewDedup,
);
});
it('should deduplicate and save mockANewLocal', async () => {
mockFs({
'/yarn.lock': mockANewLocal,
});
const lockfile = await Lockfile.load('/yarn.lock');
const result = lockfile.analyze({
localPackages: new Map([
[
'b',
{
packageJson: { version: '2.0.1' },
} as ExtendedPackage,
],
]),
});
expect(result).toEqual({
invalidRanges: [],
newRanges: [],
newVersions: [
{
name: 'b',
range: '^2',
oldVersion: '2.0.0',
newVersion: '0.0.0-use.local',
},
],
});
expect(lockfile.toString()).toBe(mockANewLocal);
lockfile.replaceVersions(result.newVersions);
expect(lockfile.toString()).toBe(mockANewLocalDedup);
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(
mockANewLocal,
);
await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined();
await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(
mockANewLocalDedup,
);
});
describe('diff', () => {
const lockfileLegacyA = Lockfile.parse(`${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@3:
version "3.0.1"
integrity sha512-abc1
b@2.0.x:
version "2.0.1"
integrity sha512-abc2
b@^2:
version "2.0.0"
integrity sha512-abc3
c@^1:
version "1.0.1"
integrity x
`);
const lockfileLegacyB = Lockfile.parse(`${LEGACY_HEADER}
a@^1:
version "1.0.1"
resolved "https://my-registry/a-1.0.01.tgz#abc123"
integrity sha512-xyz-other
dependencies:
b "^2"
b@2.0.x, b@^2:
version "2.0.0"
integrity sha512-abc3
b@4:
version "4.0.0"
integrity sha512-abc
d@^1:
version "1.0.1"
integrity x
`);
const lockfileModernA = Lockfile.parse(`${MODERN_HEADER}
"a@npm:^1":
version: "1.0.1"
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
checksum: sha512-xyz
dependencies:
b: "^2"
"b@npm:3":
version: "3.0.1"
checksum: sha512-abc1
"b@npm:2.0.x":
version: "2.0.1"
checksum: sha512-abc2
"b@npm:^2":
version: "2.0.0"
checksum: sha512-abc3
"c@npm:^1":
version: "1.0.1"
checksum: x
`);
const lockfileModernB = Lockfile.parse(`${MODERN_HEADER}
"a@npm:^1":
version: "1.0.1"
resolution: "a@npm:1.0.1"
checksum: sha512-xyz-other
dependencies:
b: "^2"
"b@npm:2.0.x, b@npm:^2":
version: "2.0.0"
checksum: sha512-abc3
"b@npm:4":
version: "4.0.0"
checksum: sha512-abc
"d@npm:^1":
version: "1.0.1"
checksum: x
`);
it('should diff two legacy lockfiles', async () => {
expect(lockfileLegacyA.diff(lockfileLegacyB)).toEqual({
added: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
});
expect(lockfileLegacyB.diff(lockfileLegacyA)).toEqual({
added: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
});
});
it('should diff two modern lockfiles', async () => {
expect(lockfileModernA.diff(lockfileModernB)).toEqual({
added: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
});
expect(lockfileModernB.diff(lockfileModernA)).toEqual({
added: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
});
});
it('should diff legacy and modern lockfiles', async () => {
expect(lockfileLegacyA.diff(lockfileModernB)).toEqual({
added: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
});
expect(lockfileLegacyB.diff(lockfileModernA)).toEqual({
added: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
});
});
it('should diff modern and legacy lockfiles', async () => {
expect(lockfileModernA.diff(lockfileLegacyB)).toEqual({
added: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
});
expect(lockfileModernB.diff(lockfileLegacyA)).toEqual({
added: [
{ name: 'b', range: '4' },
{ name: 'd', range: '^1' },
],
changed: [
{ name: 'a', range: '^1' },
{ name: 'b', range: '2.0.x' },
],
removed: [
{ name: 'b', range: '3' },
{ name: 'c', range: '^1' },
],
});
});
it('should handle workspace ranges', async () => {
const lockfile = `${MODERN_HEADER}
"@backstage/app-defaults@workspace:^, @backstage/app-defaults@workspace:packages/app-defaults":
version: 0.0.0-use.local
resolution: "@backstage/app-defaults@workspace:packages/app-defaults"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@types/node": ^16.11.26
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/backend-app-api@workspace:^, @backstage/backend-app-api@workspace:packages/backend-app-api":
version: 0.0.0-use.local
resolution: "@backstage/backend-app-api@workspace:packages/backend-app-api"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
express: ^4.17.1
express-promise-router: ^4.1.0
winston: ^3.2.1
languageName: unknown
linkType: soft
`;
expect(Lockfile.parse(lockfile).diff(Lockfile.parse(lockfile))).toEqual({
added: [],
changed: [],
removed: [],
});
});
});
describe('createSimplifiedDependencyGraph', () => {
it('for modern lockfile', () => {
expect(
Lockfile.parse(
`${MODERN_HEADER}
"@backstage/app-defaults@workspace:^, @backstage/app-defaults@workspace:packages/app-defaults":
version: 0.0.0-use.local
resolution: "@backstage/app-defaults@workspace:packages/app-defaults"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@types/node": ^16.11.26
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/backend-app-api@workspace:^, @backstage/backend-app-api@workspace:packages/backend-app-api":
version: 0.0.0-use.local
resolution: "@backstage/backend-app-api@workspace:packages/backend-app-api"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
express: ^4.17.1
express-promise-router: ^4.1.0
winston: ^3.2.1
languageName: unknown
linkType: soft
`,
).createSimplifiedDependencyGraph(),
).toEqual(
new Map([
[
'@backstage/app-defaults',
new Set([
'@backstage/cli',
'@backstage/core-app-api',
'@backstage/core-components',
'@backstage/core-plugin-api',
'@backstage/plugin-permission-react',
'@backstage/test-utils',
'@backstage/theme',
'@material-ui/core',
'@material-ui/icons',
'@testing-library/jest-dom',
'@testing-library/react',
'@types/node',
'@types/react',
'react',
'react-dom',
'react-router-dom',
]),
],
[
'@backstage/backend-app-api',
new Set([
'@backstage/backend-common',
'@backstage/backend-plugin-api',
'@backstage/backend-tasks',
'@backstage/cli',
'@backstage/errors',
'@backstage/plugin-permission-node',
'express',
'express-promise-router',
'winston',
]),
],
]),
);
});
it('for simple lockfile without dependencies', () => {
expect(
Lockfile.parse(
`${MODERN_HEADER}
"a@npm:^1":
version: "1.0.1"
"b@npm:3":
version: "3.0.1"
"b@npm:2.0.x":
version: "2.0.1"
checksum: sha512-abc2
`,
).createSimplifiedDependencyGraph(),
).toEqual(
new Map([
['a', new Set()],
['b', new Set()],
]),
);
});
it('for lockfile with dependencies', () => {
expect(
Lockfile.parse(
`${MODERN_HEADER}
"a@npm:^1":
version: "1.0.1"
dependencies:
b: "^2"
"b@npm:3":
version: "3.0.1"
checksum: sha512-abc1
"b@npm:2.0.x":
version: "2.0.1"
checksum: sha512-abc2
dependencies:
c: "^1"
"b@npm:^2":
version: "2.0.0"
checksum: sha512-abc3
peerDependencies:
d: "^1"
"c@npm:^1":
version: "1.0.1"
"d@npm:^1":
version: "1.0.2"
`,
).createSimplifiedDependencyGraph(),
).toEqual(
new Map([
['a', new Set(['b'])],
['b', new Set(['c', 'd'])],
['c', new Set()],
['d', new Set()],
]),
);
});
it('for legacy lockfile', () => {
expect(
Lockfile.parse(
`${LEGACY_HEADER}
a@^1:
version "1.0.1"
dependencies:
b "^2"
b@3:
version "3.0.1"
integrity sha512-abc1
b@2.0.x:
version "2.0.1"
integrity sha512-abc2
dependencies:
c "^1"
b@^2:
version "2.0.0"
integrity sha512-abc3
dependencies:
d "^1"
c@^1:
version "1.0.1"
integrity x
d@^1:
version "1.0.1"
integrity x
`,
).createSimplifiedDependencyGraph(),
).toEqual(
new Map([
['a', new Set(['b'])],
['b', new Set(['c', 'd'])],
['c', new Set()],
['d', new Set()],
]),
);
});
});
});
@@ -0,0 +1,430 @@
/*
* 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 semver from 'semver';
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
import { ExtendedPackage } from '../monorepo';
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;
};
type LockfileDiffEntry = {
name: string;
range: string;
};
type LockfileDiff = {
added: LockfileDiffEntry[];
changed: LockfileDiffEntry[];
removed: LockfileDiffEntry[];
};
/** Entries that have an invalid version range, for example an npm tag */
type AnalyzeResultInvalidRange = {
name: string;
range: string;
};
/** Entries that can be deduplicated by bumping to an existing higher version */
type AnalyzeResultNewVersion = {
name: string;
range: string;
oldVersion: string;
newVersion: string;
};
/** Entries that would need a dependency update in package.json to be deduplicated */
type AnalyzeResultNewRange = {
name: string;
oldRange: string;
newRange: string;
oldVersion: string;
newVersion: string;
};
type AnalyzeResult = {
invalidRanges: AnalyzeResultInvalidRange[];
newVersions: AnalyzeResultNewVersion[];
newRanges: AnalyzeResultNewRange[];
};
// 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 constructor(
private readonly packages: Map<string, LockfileQueryEntry[]>,
private readonly data: LockfileData,
private readonly legacy: boolean = false,
) {}
/** 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();
}
/** Analyzes the lockfile to identify possible actions and warnings for the entries */
analyze(options: {
filter?: (name: string) => boolean;
localPackages: Map<string, ExtendedPackage>;
}): AnalyzeResult {
const { filter, localPackages } = options;
const result: AnalyzeResult = {
invalidRanges: [],
newVersions: [],
newRanges: [],
};
for (const [name, allEntries] of this.packages) {
if (filter && !filter(name)) {
continue;
}
// Get rid of and signal any invalid ranges upfront
const invalid = allEntries.filter(
e => !semver.validRange(e.range) && !e.range.startsWith('workspace:'),
);
result.invalidRanges.push(
...invalid.map(({ range }) => ({ name, range })),
);
// Grab all valid entries, if there aren't at least 2 different valid ones we're done
const entries = allEntries.filter(e => semver.validRange(e.range));
if (entries.length < 2) {
continue;
}
// Find all versions currently in use
const versions = Array.from(new Set(entries.map(e => e.version)))
.map(v => {
// Translate workspace:^ references to the actual version
if (v === '0.0.0-use.local') {
const local = localPackages.get(name);
if (!local) {
throw new Error(`No local package found for ${name}`);
}
if (!local.packageJson.version) {
throw new Error(`No version found for local package ${name}`);
}
return {
entryVersion: v,
actualVersion: local.packageJson.version,
};
}
return { entryVersion: v, actualVersion: v };
})
.sort((v1, v2) => semver.rcompare(v1.actualVersion, v2.actualVersion));
// If we're not using at least 2 different versions we're done
if (versions.length < 2) {
continue;
}
// TODO(Rugvip): Support bumping into workspace ranges too
const acceptedVersions = new Set<string>();
for (const { version, range } of entries) {
// Finds the highest matching version from the the known versions
// TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one
const acceptedVersion = versions.find(v =>
semver.satisfies(v.actualVersion, range),
);
if (!acceptedVersion) {
throw new Error(
`No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`,
);
}
if (acceptedVersion.entryVersion !== version) {
result.newVersions.push({
name,
range,
newVersion: acceptedVersion.entryVersion,
oldVersion: version,
});
}
acceptedVersions.add(acceptedVersion.actualVersion);
}
// If all ranges were able to accept the same version, we're done
if (acceptedVersions.size === 1) {
continue;
}
// Find the max version that we may want bump older packages to
const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0];
// Find all existing ranges that satisfy the new max version, and pick the one that
// results in the highest minimum allowed version, usually being the more specific one
const maxEntry = entries
.filter(e => semver.satisfies(maxVersion, e.range))
.map(e => ({ e, min: semver.minVersion(e.range) }))
.filter(p => p.min)
.sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e;
if (!maxEntry) {
throw new Error(
`No entry found that satisfies max version '${maxVersion}'`,
);
}
// Find all entries that don't satisfy the max version
for (const { version, range } of entries) {
if (semver.satisfies(maxVersion, range)) {
continue;
}
result.newRanges.push({
name,
oldRange: range,
newRange: maxEntry.range,
oldVersion: version,
newVersion: maxVersion,
});
}
}
return result;
}
remove(name: string, range: string): boolean {
const query = `${name}@${range}`;
const existed = Boolean(this.data[query]);
delete this.data[query];
const newEntries = this.packages.get(name)?.filter(e => e.range !== range);
if (newEntries) {
this.packages.set(name, newEntries);
}
return existed;
}
/** Modifies the lockfile by bumping packages to the suggested versions */
replaceVersions(results: AnalyzeResultNewVersion[]) {
for (const { name, range, oldVersion, newVersion } of results) {
const query = `${name}@${range}`;
// Update the backing data
const entryData = this.data[query];
if (!entryData) {
throw new Error(`No entry data for ${query}`);
}
if (entryData.version !== oldVersion) {
throw new Error(
`Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
);
}
// Modifying the data in the entry is not enough, we need to reference an existing version object
const matchingEntry = Object.entries(this.data).find(
([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion,
);
if (!matchingEntry) {
throw new Error(
`No matching entry found for ${name} at version ${newVersion}`,
);
}
this.data[query] = matchingEntry[1];
// Update our internal data structure
const entry = this.packages.get(name)?.find(e => e.range === range);
if (!entry) {
throw new Error(`No entry data for ${query}`);
}
if (entry.version !== oldVersion) {
throw new Error(
`Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`,
);
}
entry.version = newVersion;
}
}
createSimplifiedDependencyGraph(): Map<string, Set<string>> {
const graph = new Map<string, Set<string>>();
for (const [name, entries] of this.packages) {
const dependencies = new Set(
entries.flatMap(e => {
const data = this.data[e.dataKey];
return [
...Object.keys(data?.dependencies ?? {}),
...Object.keys(data?.peerDependencies ?? {}),
];
}),
);
graph.set(name, dependencies);
}
return graph;
}
/**
* Diff with another lockfile, returning entries that have been
* added, changed, and removed compared to the other lockfile.
*/
diff(otherLockfile: Lockfile): LockfileDiff {
const diff = {
added: new Array<{ name: string; range: string }>(),
changed: new Array<{ name: string; range: string }>(),
removed: new Array<{ name: string; range: string }>(),
};
// Keeps track of packages that only exist in this lockfile
const remainingOldNames = new Set(this.packages.keys());
for (const [name, otherQueries] of otherLockfile.packages) {
remainingOldNames.delete(name);
const thisQueries = this.packages.get(name);
// If the packages doesn't exist in this lockfile, add all entries
if (!thisQueries) {
diff.removed.push(...otherQueries.map(q => ({ name, range: q.range })));
continue;
}
const remainingOldRanges = new Set(thisQueries.map(q => q.range));
for (const otherQuery of otherQueries) {
remainingOldRanges.delete(otherQuery.range);
const thisQuery = thisQueries.find(q => q.range === otherQuery.range);
if (!thisQuery) {
diff.removed.push({ name, range: otherQuery.range });
continue;
}
const otherPkg = otherLockfile.data[otherQuery.dataKey];
const thisPkg = this.data[thisQuery.dataKey];
if (otherPkg && thisPkg) {
const thisCheck = thisPkg.integrity || thisPkg.checksum;
const otherCheck = otherPkg.integrity || otherPkg.checksum;
if (thisCheck !== otherCheck) {
diff.changed.push({ name, range: otherQuery.range });
}
}
}
for (const thisRange of remainingOldRanges) {
diff.added.push({ name, range: thisRange });
}
}
for (const name of remainingOldNames) {
const queries = this.packages.get(name) ?? [];
diff.added.push(...queries.map(q => ({ name, range: q.range })));
}
return diff;
}
async save(path: string) {
await fs.writeFile(path, this.toString(), 'utf8');
}
toString() {
return this.legacy
? legacyStringifyLockfile(this.data)
: NEW_HEADER + stringifySyml(this.data);
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* 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';
@@ -0,0 +1,155 @@
/*
* 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 mockFs from 'mock-fs';
import path from 'path';
import * as runObj from '../run';
import * as yarn from '../yarn';
import { fetchPackageInfo, mapDependencies } from './packages';
import { NotFoundError } from '../errors';
jest.mock('../run', () => {
return {
run: jest.fn(),
execFile: jest.fn(),
};
});
jest.mock('../yarn', () => {
return {
detectYarnVersion: jest.fn(),
};
});
describe('fetchPackageInfo', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should forward info for yarn classic', async () => {
jest.spyOn(runObj, 'execFile').mockResolvedValue({
stdout: `{"type":"inspect","data":{"the":"data"}}`,
stderr: '',
});
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
['info', '--json', 'my-package'],
{ shell: true },
);
});
it('should forward info for yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: `{"the":"data"}`, stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
['npm', 'info', '--json', 'my-package'],
{ shell: true },
);
});
it('should throw if no info with yarn classic', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: '', stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
it('should throw if no info with yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockRejectedValue({ stdout: 'bla bla bla Response Code: 404 bla bla' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
});
describe('mapDependencies', () => {
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
});
it('should read dependencies', async () => {
mockFs({
'/root/package.json': JSON.stringify({
workspaces: {
packages: ['pkgs/*'],
},
}),
'/root/pkgs/a/package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '1 || 2',
},
}),
'/root/pkgs/b/package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '3',
'@backstage/cli': '^0',
},
}),
});
const dependencyMap = await mapDependencies('/root', '@backstage/*');
expect(Array.from(dependencyMap)).toEqual([
[
'@backstage/core',
[
{
name: 'a',
range: '1 || 2',
location: path.resolve('/root/pkgs/a'),
},
{
name: 'b',
range: '3',
location: path.resolve('/root/pkgs/b'),
},
],
],
[
'@backstage/cli',
[
{
name: 'b',
range: '^0',
location: path.resolve('/root/pkgs/b'),
},
],
],
]);
});
});
@@ -0,0 +1,125 @@
/*
* 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 minimatch from 'minimatch';
import { getPackages } from '@manypkg/get-packages';
import { NotFoundError } from '../errors';
import { detectYarnVersion } from '../yarn';
import { execFile } from '../run';
const DEP_TYPES = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
] as const;
// Package data as returned by `yarn info`
export type YarnInfoInspectData = {
name: string;
'dist-tags': Record<string, string>;
versions: string[];
time: { [version: string]: string };
};
// Possible `yarn info` output
type YarnInfo = {
type: 'inspect';
data: YarnInfoInspectData | { type: string; data: unknown };
};
type PkgVersionInfo = {
range: string;
name: string;
location: string;
};
export async function fetchPackageInfo(
name: string,
): Promise<YarnInfoInspectData> {
const yarnVersion = await detectYarnVersion();
const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info'];
try {
const { stdout: output } = await execFile(
'yarn',
[...cmd, '--json', name],
{ shell: true },
);
if (!output) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
if (yarnVersion === 'berry') {
return JSON.parse(output) as YarnInfoInspectData;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
} catch (error) {
if (yarnVersion === 'classic') {
throw error;
}
if (error?.stdout.includes('Response Code: 404')) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
throw error;
}
}
/** Map all dependencies in the repo as dependency => dependents */
export async function mapDependencies(
targetDir: string,
pattern: string,
): Promise<Map<string, PkgVersionInfo[]>> {
const { packages, root } = await getPackages(targetDir);
// Include root package.json too
packages.push(root);
const dependencyMap = new Map<string, PkgVersionInfo[]>();
for (const pkg of packages) {
const deps = DEP_TYPES.flatMap(
t => Object.entries(pkg.packageJson[t] ?? {}) as [string, string][],
);
for (const [name, range] of deps) {
if (minimatch(name, pattern)) {
dependencyMap.set(
name,
(dependencyMap.get(name) ?? []).concat({
range,
name: pkg.packageJson.name,
location: pkg.dir,
}),
);
}
}
}
return dependencyMap;
}