cli: forklift monorepo utils to cli-node
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user