diff --git a/.changeset/bright-hats-compare.md b/.changeset/bright-hats-compare.md new file mode 100644 index 0000000000..148eaa29ce --- /dev/null +++ b/.changeset/bright-hats-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': minor +--- + +Introduced the new `@backstage/cli-node` package, which provides utilities for use across Backstage CLIs. diff --git a/.changeset/forty-adults-divide.md b/.changeset/forty-adults-divide.md new file mode 100644 index 0000000000..55d1e3f5d8 --- /dev/null +++ b/.changeset/forty-adults-divide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor to move many internal utilities to the new `@backstage/cli-node` package. diff --git a/packages/cli-node/.eslintrc.js b/packages/cli-node/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-node/README.md b/packages/cli-node/README.md new file mode 100644 index 0000000000..7988ac1e52 --- /dev/null +++ b/packages/cli-node/README.md @@ -0,0 +1,10 @@ +# @backstage/cli-node + +This library provides utilities for building CLI tools for Backstage. + +The difference between this library and `@backstage/cli-common` is that this library is more feature rich with a larger dependency tree, with less concern for bundle size and installation speed. The `@backstage/cli-common` package on the other hand is intended to be extremely slim and only provide minimal features for use in tools like `@backstage/create-app`. + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/cli-node/api-report.md b/packages/cli-node/api-report.md new file mode 100644 index 0000000000..91148596b3 --- /dev/null +++ b/packages/cli-node/api-report.md @@ -0,0 +1,167 @@ +## API Report File for "@backstage/cli-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { JsonValue } from '@backstage/types'; +import { Package } from '@manypkg/get-packages'; + +// @public +export type BackstagePackage = { + dir: string; + packageJson: BackstagePackageJson; +}; + +// @public +export interface BackstagePackageJson { + // (undocumented) + backstage?: { + role?: PackageRole; + }; + // (undocumented) + bundled?: boolean; + // (undocumented) + dependencies?: { + [key: string]: string; + }; + // (undocumented) + devDependencies?: { + [key: string]: string; + }; + // (undocumented) + exports?: JsonValue; + // (undocumented) + files?: string[]; + // (undocumented) + main?: string; + // (undocumented) + module?: string; + // (undocumented) + name: string; + // (undocumented) + optionalDependencies?: { + [key: string]: string; + }; + // (undocumented) + peerDependencies?: { + [key: string]: string; + }; + // (undocumented) + private?: boolean; + // (undocumented) + publishConfig?: { + access?: 'public' | 'restricted'; + directory?: string; + registry?: string; + alphaTypes?: string; + betaTypes?: string; + }; + // (undocumented) + scripts?: { + [key: string]: string; + }; + // (undocumented) + types?: string; + // (undocumented) + typesVersions?: Record>; + // (undocumented) + version: string; +} + +// @public +export class GitUtils { + static listChangedFiles(ref: string): Promise; + static readFileAtRef(path: string, ref: string): Promise; +} + +// @public +export function isMonoRepo(): Promise; + +// @public +export class Lockfile { + createSimplifiedDependencyGraph(): Map>; + diff(otherLockfile: Lockfile): LockfileDiff; + static load(path: string): Promise; + static parse(content: string): Lockfile; +} + +// @public +export type LockfileDiff = { + added: LockfileDiffEntry[]; + changed: LockfileDiffEntry[]; + removed: LockfileDiffEntry[]; +}; + +// @public +export type LockfileDiffEntry = { + name: string; + range: string; +}; + +// @public +export class PackageGraph extends Map { + collectPackageNames( + startingPackageNames: string[], + collectFn: (pkg: PackageGraphNode) => Iterable | undefined, + ): Set; + static fromPackages(packages: Package[]): PackageGraph; + listChangedPackages(options: { + ref: string; + analyzeLockfile?: boolean; + }): Promise; + static listTargetPackages(): Promise; +} + +// @public +export type PackageGraphNode = { + name: string; + dir: string; + packageJson: BackstagePackageJson; + allLocalDependencies: Map; + publishedLocalDependencies: Map; + localDependencies: Map; + localDevDependencies: Map; + localOptionalDependencies: Map; + allLocalDependents: Map; + publishedLocalDependents: Map; + localDependents: Map; + localDevDependents: Map; + localOptionalDependents: Map; +}; + +// @public +export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs'; + +// @public +export type PackagePlatform = 'node' | 'web' | 'common'; + +// @public +export type PackageRole = + | 'frontend' + | 'backend' + | 'cli' + | 'web-library' + | 'node-library' + | 'common-library' + | 'frontend-plugin' + | 'frontend-plugin-module' + | 'backend-plugin' + | 'backend-plugin-module'; + +// @public +export interface PackageRoleInfo { + // (undocumented) + output: PackageOutputType[]; + // (undocumented) + platform: PackagePlatform; + // (undocumented) + role: PackageRole; +} + +// @public +export class PackageRoles { + static detectRoleFromPackage(pkgJson: unknown): PackageRole | undefined; + static getRoleFromPackage(pkgJson: unknown): PackageRole | undefined; + static getRoleInfo(role: string): PackageRoleInfo; +} +``` diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json new file mode 100644 index 0000000000..4f044e9710 --- /dev/null +++ b/packages/cli-node/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/cli-node", + "description": "Node.js library for Backstage CLIs", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/cli-common": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", + "@manypkg/get-packages": "^1.1.3", + "@yarnpkg/parsers": "^3.0.0-rc.4", + "fs-extra": "10.1.0", + "semver": "^7.3.2", + "zod": "^3.21.4" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "mock-fs": "^5.2.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli/src/lib/git.test.ts b/packages/cli-node/src/git/GitUtils.test.ts similarity index 85% rename from packages/cli/src/lib/git.test.ts rename to packages/cli-node/src/git/GitUtils.test.ts index 2e5d464796..e1ebe5513c 100644 --- a/packages/cli/src/lib/git.test.ts +++ b/packages/cli-node/src/git/GitUtils.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { runGit, listChangedFiles } from './git'; +import { runGit, GitUtils } from './GitUtils'; describe('runGit', () => { it('runs a git command', async () => { @@ -43,10 +43,14 @@ describe('runGit', () => { describe('listChangedFiles', () => { it('requires a ref', async () => { - await expect(listChangedFiles('')).rejects.toThrow('ref is required'); + await expect(GitUtils.listChangedFiles('')).rejects.toThrow( + 'ref is required', + ); }); it('should return something', async () => { - await expect(listChangedFiles('HEAD')).resolves.toEqual(expect.any(Array)); + await expect(GitUtils.listChangedFiles('HEAD')).resolves.toEqual( + expect.any(Array), + ); }); }); diff --git a/packages/cli-node/src/git/GitUtils.ts b/packages/cli-node/src/git/GitUtils.ts new file mode 100644 index 0000000000..df04b1c6a2 --- /dev/null +++ b/packages/cli-node/src/git/GitUtils.ts @@ -0,0 +1,94 @@ +/* + * 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 { assertError, ForwardedError } from '@backstage/errors'; +import { paths } from '../paths'; +import { execFile } from '../run'; + +/** + * Run a git command, trimming the output splitting it into lines. + */ +export async function runGit(...args: string[]) { + try { + const { stdout } = await execFile('git', args, { + shell: true, + cwd: paths.targetRoot, + }); + return stdout.trim().split(/\r\n|\r|\n/); + } catch (error) { + assertError(error); + if (error.stderr || typeof error.code === 'number') { + const stderr = (error.stderr as undefined | Buffer)?.toString('utf8'); + const msg = stderr?.trim() ?? `with exit code ${error.code}`; + throw new Error(`git ${args[0]} failed, ${msg}`); + } + throw new ForwardedError('Unknown execution error', error); + } +} + +/** + * Utilities for working with git. + * + * @public + */ +export class GitUtils { + /** + * Returns a sorted list of all files that have changed since the merge base + * of the provided `ref` and HEAD, as well as all files that are not tracked by git. + */ + static async listChangedFiles(ref: string) { + if (!ref) { + throw new Error('ref is required'); + } + + let diffRef = ref; + try { + const [base] = await runGit('merge-base', 'HEAD', ref); + diffRef = base; + } catch { + // silently fall back to using the ref directly if merge base is not available + } + + const tracked = await runGit('diff', '--name-only', diffRef); + const untracked = await runGit( + 'ls-files', + '--others', + '--exclude-standard', + ); + + return Array.from(new Set([...tracked, ...untracked])); + } + + /** + * Returns the contents of a file at a specific ref. + */ + static async readFileAtRef(path: string, ref: string) { + let showRef = ref; + try { + const [base] = await runGit('merge-base', 'HEAD', ref); + showRef = base; + } catch { + // silently fall back to using the ref directly if merge base is not available + } + + const { stdout } = await execFile('git', ['show', `${showRef}:${path}`], { + shell: true, + cwd: paths.targetRoot, + maxBuffer: 1024 * 1024 * 50, + }); + return stdout; + } +} diff --git a/packages/cli/src/lib/monorepo/index.ts b/packages/cli-node/src/git/index.ts similarity index 75% rename from packages/cli/src/lib/monorepo/index.ts rename to packages/cli-node/src/git/index.ts index 7156975c07..b4dd156ed2 100644 --- a/packages/cli/src/lib/monorepo/index.ts +++ b/packages/cli-node/src/git/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Backstage Authors + * 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. @@ -14,9 +14,4 @@ * limitations under the License. */ -export { PackageGraph } from './PackageGraph'; -export type { - PackageGraphNode, - ExtendedPackage, - ExtendedPackageJSON, -} from './PackageGraph'; +export { GitUtils } from './GitUtils'; diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts new file mode 100644 index 0000000000..1d3f3ec2ed --- /dev/null +++ b/packages/cli-node/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * Node.js library for Backstage CLIs + * + * @packageDocumentation + */ + +export * from './git'; +export * from './monorepo'; +export * from './roles'; diff --git a/packages/cli-node/src/monorepo/Lockfile.test.ts b/packages/cli-node/src/monorepo/Lockfile.test.ts new file mode 100644 index 0000000000..01574779b2 --- /dev/null +++ b/packages/cli-node/src/monorepo/Lockfile.test.ts @@ -0,0 +1,501 @@ +/* + * 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 { 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 +`; + +describe('New Lockfile', () => { + afterEach(() => { + mockFs.restore(); + }); + + 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()], + ]), + ); + }); + }); +}); diff --git a/packages/cli-node/src/monorepo/Lockfile.ts b/packages/cli-node/src/monorepo/Lockfile.ts new file mode 100644 index 0000000000..8a481c77ad --- /dev/null +++ b/packages/cli-node/src/monorepo/Lockfile.ts @@ -0,0 +1,218 @@ +/* + * 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 { parseSyml } from '@yarnpkg/parsers'; +import fs from 'fs-extra'; + +const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; + +/** @internal */ +type LockfileData = { + [entry: string]: { + version: string; + resolved?: string; + integrity?: string /* old */; + checksum?: string /* new */; + dependencies?: { [name: string]: string }; + peerDependencies?: { [name: string]: string }; + }; +}; + +/** @internal */ +type LockfileQueryEntry = { + range: string; + version: string; + dataKey: string; +}; + +/** + * An entry for a single difference between two {@link Lockfile}s. + * + * @public + */ +export type LockfileDiffEntry = { + name: string; + range: string; +}; + +/** + * Represents the difference between two {@link Lockfile}s. + * + * @public + */ +export type LockfileDiff = { + added: LockfileDiffEntry[]; + changed: LockfileDiffEntry[]; + removed: LockfileDiffEntry[]; +}; + +// 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`, +]; + +/** + * Represents a package manager lockfile. + * + * @public + */ +export class Lockfile { + /** + * Load a {@link Lockfile} from a file path. + */ + static async load(path: string): Promise { + const lockfileContents = await fs.readFile(path, 'utf8'); + return Lockfile.parse(lockfileContents); + } + + /** + * Parse lockfile contents into a {@link Lockfile}. + * + * @public + */ + static parse(content: string): Lockfile { + let data: LockfileData; + try { + data = parseSyml(content); + } catch (err) { + throw new Error(`Failed yarn.lock parse, ${err}`); + } + + const packages = new Map(); + + 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); + } + + private constructor( + private readonly packages: Map, + private readonly data: LockfileData, + ) {} + + /** + * Creates a simplified dependency graph from the lockfile data, where each + * key is a package, and the value is a set of all packages that it depends on + * across all versions. + */ + createSimplifiedDependencyGraph(): Map> { + const graph = new Map>(); + + 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; + } +} diff --git a/packages/cli/src/lib/monorepo/PackageGraph.test.ts b/packages/cli-node/src/monorepo/PackageGraph.test.ts similarity index 94% rename from packages/cli/src/lib/monorepo/PackageGraph.test.ts rename to packages/cli-node/src/monorepo/PackageGraph.test.ts index d8edff9c81..9bc9cbf72b 100644 --- a/packages/cli/src/lib/monorepo/PackageGraph.test.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.test.ts @@ -17,17 +17,11 @@ 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'; +import { Lockfile } from './Lockfile'; +import { GitUtils } from '../git'; -jest.mock('../git'); - -const mockListChangedFiles = listChangedFiles as jest.MockedFunction< - typeof listChangedFiles ->; -const mockReadFileAtRef = readFileAtRef as jest.MockedFunction< - typeof readFileAtRef ->; +const mockListChangedFiles = jest.spyOn(GitUtils, 'listChangedFiles'); +const mockReadFileAtRef = jest.spyOn(GitUtils, 'readFileAtRef'); jest.mock('../paths', () => ({ paths: { diff --git a/packages/cli/src/lib/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts similarity index 86% rename from packages/cli/src/lib/monorepo/PackageGraph.ts rename to packages/cli-node/src/monorepo/PackageGraph.ts index bb203c01a5..421c49f9b9 100644 --- a/packages/cli/src/lib/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -17,14 +17,21 @@ 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 { PackageRole } from '../roles'; +import { GitUtils } from '../git'; +import { Lockfile } from './Lockfile'; import { JsonValue } from '@backstage/types'; -type PackageJSON = Package['packageJson']; +/** + * Known fields in Backstage package.json files. + * + * @public + */ +export interface BackstagePackageJson { + name: string; + version: string; + private?: boolean; -export interface ExtendedPackageJSON extends PackageJSON { main?: string; module?: string; types?: string; @@ -52,20 +59,43 @@ export interface ExtendedPackageJSON extends PackageJSON { alphaTypes?: string; betaTypes?: string; }; + + dependencies?: { + [key: string]: string; + }; + peerDependencies?: { + [key: string]: string; + }; + devDependencies?: { + [key: string]: string; + }; + optionalDependencies?: { + [key: string]: string; + }; } -export type ExtendedPackage = { +/** + * A local Backstage monorepo package + * + * @public + */ +export type BackstagePackage = { dir: string; - packageJson: ExtendedPackageJSON; + packageJson: BackstagePackageJson; }; +/** + * A local package in the monorepo package graph. + * + * @public + */ 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; + packageJson: BackstagePackageJson; /** All direct local dependencies of the package */ allLocalDependencies: Map; @@ -90,12 +120,23 @@ export type PackageGraphNode = { localOptionalDependents: Map; }; +/** + * Represents a local Backstage monorepo package graph. + * + * @public + */ export class PackageGraph extends Map { - static async listTargetPackages(): Promise { + /** + * Lists all local packages in a monorepo. + */ + static async listTargetPackages(): Promise { const { packages } = await getPackages(paths.targetDir); - return packages as ExtendedPackage[]; + return packages as BackstagePackage[]; } + /** + * Creates a package graph from a list of local packages. + */ static fromPackages(packages: Package[]): PackageGraph { const graph = new PackageGraph(); @@ -112,7 +153,7 @@ export class PackageGraph extends Map { graph.set(name, { name, dir: pkg.dir, - packageJson: pkg.packageJson as ExtendedPackageJSON, + packageJson: pkg.packageJson as BackstagePackageJson, allLocalDependencies: new Map(), publishedLocalDependencies: new Map(), @@ -210,11 +251,19 @@ export class PackageGraph extends Map { return targets; } + /** + * Lists all packages that have changed since a given git ref. + * + * @remarks + * + * If the `analyzeLockfile` option is set to true, the change detection will + * also consider changes to the dependency management lockfile. + */ async listChangedPackages(options: { ref: string; analyzeLockfile?: boolean; }) { - const changedFiles = await listChangedFiles(options.ref); + const changedFiles = await GitUtils.listChangedFiles(options.ref); const dirMap = new Map( Array.from(this.values()).map(pkg => [ @@ -265,7 +314,7 @@ export class PackageGraph extends Map { paths.resolveTargetRoot('yarn.lock'), ); otherLockfile = Lockfile.parse( - await readFileAtRef('yarn.lock', options.ref), + await GitUtils.readFileAtRef('yarn.lock', options.ref), ); } catch (error) { console.warn( diff --git a/packages/cli-node/src/monorepo/index.ts b/packages/cli-node/src/monorepo/index.ts new file mode 100644 index 0000000000..fdb8395c16 --- /dev/null +++ b/packages/cli-node/src/monorepo/index.ts @@ -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 { isMonoRepo } from './isMonoRepo'; +export { + PackageGraph, + type PackageGraphNode, + type BackstagePackage, + type BackstagePackageJson, +} from './PackageGraph'; +export { + Lockfile, + type LockfileDiff, + type LockfileDiffEntry, +} from './Lockfile'; diff --git a/packages/cli/src/lib/monorepo/isMonoRepo.ts b/packages/cli-node/src/monorepo/isMonoRepo.ts similarity index 92% rename from packages/cli/src/lib/monorepo/isMonoRepo.ts rename to packages/cli-node/src/monorepo/isMonoRepo.ts index 9b1ab93762..da78c8dd53 100644 --- a/packages/cli/src/lib/monorepo/isMonoRepo.ts +++ b/packages/cli-node/src/monorepo/isMonoRepo.ts @@ -17,6 +17,11 @@ import { paths } from '../paths'; import fs from 'fs-extra'; +/** + * Returns try if the current project is a monorepo. + * + * @public + */ export async function isMonoRepo(): Promise { const rootPackageJsonPath = paths.resolveTargetRoot('package.json'); try { diff --git a/packages/cli/src/lib/monorepo/isMonorepo.test.ts b/packages/cli-node/src/monorepo/isMonorepo.test.ts similarity index 100% rename from packages/cli/src/lib/monorepo/isMonorepo.test.ts rename to packages/cli-node/src/monorepo/isMonorepo.test.ts diff --git a/packages/cli-node/src/paths.ts b/packages/cli-node/src/paths.ts new file mode 100644 index 0000000000..2c658c27b3 --- /dev/null +++ b/packages/cli-node/src/paths.ts @@ -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); diff --git a/packages/cli/src/lib/role/packageRoles.test.ts b/packages/cli-node/src/roles/PackageRoles.test.ts similarity index 82% rename from packages/cli/src/lib/role/packageRoles.test.ts rename to packages/cli-node/src/roles/PackageRoles.test.ts index 2cd1e29368..80e320f1f4 100644 --- a/packages/cli/src/lib/role/packageRoles.test.ts +++ b/packages/cli-node/src/roles/PackageRoles.test.ts @@ -14,30 +14,23 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import { Command } from 'commander'; -import { - getRoleInfo, - getRoleFromPackage, - findRoleFromCommand, - detectRoleFromPackage, -} from './packageRoles'; +import { PackageRoles } from './PackageRoles'; describe('getRoleInfo', () => { it('provides role info by role', () => { - expect(getRoleInfo('web-library')).toEqual({ + expect(PackageRoles.getRoleInfo('web-library')).toEqual({ role: 'web-library', platform: 'web', output: ['types', 'esm'], }); - expect(getRoleInfo('frontend')).toEqual({ + expect(PackageRoles.getRoleInfo('frontend')).toEqual({ role: 'frontend', platform: 'web', output: ['bundle'], }); - expect(() => getRoleInfo('invalid')).toThrow( + expect(() => PackageRoles.getRoleInfo('invalid')).toThrow( `Unknown package role 'invalid'`, ); }); @@ -46,7 +39,7 @@ describe('getRoleInfo', () => { describe('getRoleFromPackage', () => { it('reads explicit package roles', () => { expect( - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ backstage: { role: 'web-library', }, @@ -54,7 +47,7 @@ describe('getRoleFromPackage', () => { ).toEqual('web-library'); expect( - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ backstage: { role: 'frontend', }, @@ -62,14 +55,14 @@ describe('getRoleFromPackage', () => { ).toEqual('frontend'); expect(() => - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ name: 'test', backstage: {}, }), ).toThrow('Package test must specify a role in the "backstage" field'); expect(() => - getRoleFromPackage({ + PackageRoles.getRoleFromPackage({ name: 'test', backstage: { role: 'invalid' }, }), @@ -77,48 +70,10 @@ describe('getRoleFromPackage', () => { }); }); -describe('findRoleFromCommand', () => { - function mkCommand(args: string) { - const parsed = new Command() - .option('--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({ + PackageRoles.detectRoleFromPackage({ name: 'example-app', private: true, bundled: true, @@ -141,7 +96,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of example-backend', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: 'example-backend', main: 'dist/index.cjs.js', types: 'src/index.ts', @@ -160,7 +115,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog', main: 'src/index.ts', types: 'src/index.ts', @@ -185,7 +140,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-backend', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-backend', main: 'src/index.ts', types: 'src/index.ts', @@ -209,7 +164,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-react', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-react', main: 'src/index.ts', types: 'src/index.ts', @@ -232,7 +187,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-common', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-common', main: 'src/index.ts', types: 'src/index.ts', @@ -256,7 +211,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-catalog-backend-module-ldap', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-catalog-backend-module-ldap', main: 'src/index.ts', types: 'src/index.ts', @@ -279,7 +234,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-permission-node', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-permission-node', main: 'src/index.ts', types: 'src/index.ts', @@ -303,7 +258,7 @@ describe('detectRoleFromPackage', () => { it('detects the role of @backstage/plugin-analytics-module-ga', () => { expect( - detectRoleFromPackage({ + PackageRoles.detectRoleFromPackage({ name: '@backstage/plugin-analytics-module-ga', main: 'src/index.ts', types: 'src/index.ts', diff --git a/packages/cli-node/src/roles/PackageRoles.ts b/packages/cli-node/src/roles/PackageRoles.ts new file mode 100644 index 0000000000..401568ad6d --- /dev/null +++ b/packages/cli-node/src/roles/PackageRoles.ts @@ -0,0 +1,186 @@ +/* + * 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 { 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'], + }, +]; + +const readSchema = z.object({ + name: z.string().optional(), + backstage: z + .object({ + role: z.string().optional(), + }) + .optional(), +}); + +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(), +}); + +/** + * Utilities for working with Backstage package roles. + * + * @public + */ +export class PackageRoles { + /** + * Get the associated info for a package role. + */ + static getRoleInfo(role: string): PackageRoleInfo { + const roleInfo = packageRoleInfos.find(r => r.role === role); + if (!roleInfo) { + throw new Error(`Unknown package role '${role}'`); + } + return roleInfo; + } + + /** + * Given package JSON data, get the package role. + */ + static getRoleFromPackage(pkgJson: unknown): PackageRole | undefined { + const pkg = readSchema.parse(pkgJson); + + if (pkg.backstage) { + const { role } = pkg.backstage; + if (!role) { + throw new Error( + `Package ${pkg.name} must specify a role in the "backstage" field`, + ); + } + + return this.getRoleInfo(role).role; + } + + return undefined; + } + + /** + * Attempt to detect the role of a package from its package.json. + */ + static 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; + } +} diff --git a/packages/cli/src/lib/role/index.ts b/packages/cli-node/src/roles/index.ts similarity index 85% rename from packages/cli/src/lib/role/index.ts rename to packages/cli-node/src/roles/index.ts index 4e1047a628..93022ba986 100644 --- a/packages/cli/src/lib/role/index.ts +++ b/packages/cli-node/src/roles/index.ts @@ -20,9 +20,4 @@ export type { PackageOutputType, PackageRole, } from './types'; -export { - getRoleInfo, - getRoleFromPackage, - findRoleFromCommand, - detectRoleFromPackage, -} from './packageRoles'; +export { PackageRoles } from './PackageRoles'; diff --git a/packages/cli/src/lib/role/types.ts b/packages/cli-node/src/roles/types.ts similarity index 75% rename from packages/cli/src/lib/role/types.ts rename to packages/cli-node/src/roles/types.ts index a3961544f4..a913ff29b6 100644 --- a/packages/cli/src/lib/role/types.ts +++ b/packages/cli-node/src/roles/types.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +/** + * Backstage package role, see {@link https://backstage.io/docs/local-dev/cli-build-system#package-roles | docs}. + * + * @public + */ export type PackageRole = | 'frontend' | 'backend' @@ -26,9 +31,25 @@ export type PackageRole = | 'backend-plugin' | 'backend-plugin-module'; +/** + * A type of platform that a package can be built for. + * + * @public + */ export type PackagePlatform = 'node' | 'web' | 'common'; + +/** + * The type of output that a package can produce. + * + * @public + */ export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs'; +/** + * Information about a package role. + * + * @public + */ export interface PackageRoleInfo { role: PackageRole; platform: PackagePlatform; diff --git a/packages/cli-node/src/run.ts b/packages/cli-node/src/run.ts new file mode 100644 index 0000000000..e924b5c5b8 --- /dev/null +++ b/packages/cli-node/src/run.ts @@ -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 { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; + +export const execFile = promisify(execFileCb); diff --git a/packages/cli-node/src/setupTests.ts b/packages/cli-node/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/packages/cli-node/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export {}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d91a69570..85356ec75b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", "@backstage/config": "workspace:^", "@backstage/config-loader": "workspace:^", "@backstage/errors": "workspace:^", diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts index 90b76545ce..507085e0a0 100644 --- a/packages/cli/src/commands/build/command.ts +++ b/packages/cli/src/commands/build/command.ts @@ -16,7 +16,8 @@ import { OptionValues } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; -import { findRoleFromCommand, getRoleInfo } from '../../lib/role'; +import { findRoleFromCommand } from '../../lib/role'; +import { PackageRoles } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { buildFrontend } from './buildFrontend'; import { buildBackend } from './buildBackend'; @@ -47,7 +48,7 @@ export async function command(opts: OptionValues): Promise { }); } - const roleInfo = getRoleInfo(role); + const roleInfo = PackageRoles.getRoleInfo(role); const outputs = new Set(); diff --git a/packages/cli/src/commands/fix.ts b/packages/cli/src/commands/fix.ts index e4cdc4dc84..263d171fd6 100644 --- a/packages/cli/src/commands/fix.ts +++ b/packages/cli/src/commands/fix.ts @@ -19,7 +19,7 @@ import { ESLint } from 'eslint'; import { join as joinPath, basename } from 'path'; import fs from 'fs-extra'; import { isChildPath } from '@backstage/cli-common'; -import { PackageGraph } from '../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; function isTestPath(filePath: string) { if (!isChildPath(joinPath(paths.targetDir, 'src'), filePath)) { diff --git a/packages/cli/src/commands/migrate/packageExports.ts b/packages/cli/src/commands/migrate/packageExports.ts index ff01f939f9..82d0b73ac2 100644 --- a/packages/cli/src/commands/migrate/packageExports.ts +++ b/packages/cli/src/commands/migrate/packageExports.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { ExtendedPackageJSON, PackageGraph } from '../../lib/monorepo'; +import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; function trimRelative(path: string): string { if (path.startsWith('./')) { @@ -94,7 +94,7 @@ export async function command() { newPackageJson = Object.fromEntries( newPkgEntries, - ) as ExtendedPackageJSON; + ) as BackstagePackageJson; changed = true; } diff --git a/packages/cli/src/commands/migrate/packageLintConfigs.ts b/packages/cli/src/commands/migrate/packageLintConfigs.ts index c0007ef59d..03d42846e8 100644 --- a/packages/cli/src/commands/migrate/packageLintConfigs.ts +++ b/packages/cli/src/commands/migrate/packageLintConfigs.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { PackageGraph } from '../../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; import { runPlain } from '../../lib/run'; const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`; diff --git a/packages/cli/src/commands/migrate/packageRole.ts b/packages/cli/src/commands/migrate/packageRole.ts index e8bfb8e7c8..e7abbd3dcd 100644 --- a/packages/cli/src/commands/migrate/packageRole.ts +++ b/packages/cli/src/commands/migrate/packageRole.ts @@ -17,8 +17,8 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; import { getPackages } from '@manypkg/get-packages'; +import { PackageRoles } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; -import { getRoleFromPackage, detectRoleFromPackage } from '../../lib/role'; export default async () => { const { packages } = await getPackages(paths.targetDir); @@ -26,12 +26,12 @@ export default async () => { await Promise.all( packages.map(async ({ dir, packageJson: pkg }) => { const { name } = pkg; - const existingRole = getRoleFromPackage(pkg); + const existingRole = PackageRoles.getRoleFromPackage(pkg); if (existingRole) { return; } - const detectedRole = detectRoleFromPackage(pkg); + const detectedRole = PackageRoles.detectRoleFromPackage(pkg); if (!detectedRole) { console.error(`No role detected for package ${name}`); return; diff --git a/packages/cli/src/commands/migrate/packageScripts.ts b/packages/cli/src/commands/migrate/packageScripts.ts index c7dab9466b..1552208a5f 100644 --- a/packages/cli/src/commands/migrate/packageScripts.ts +++ b/packages/cli/src/commands/migrate/packageScripts.ts @@ -16,8 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { PackageGraph } from '../../lib/monorepo'; -import { getRoleFromPackage, getRoleInfo, PackageRole } from '../../lib/role'; +import { PackageGraph, PackageRoles, PackageRole } from '@backstage/cli-node'; const configArgPattern = /--config[=\s][^\s$]+/; @@ -28,12 +27,12 @@ export async function command() { await Promise.all( packages.map(async ({ dir, packageJson }) => { - const role = getRoleFromPackage(packageJson); + const role = PackageRoles.getRoleFromPackage(packageJson); if (!role) { return; } - const roleInfo = getRoleInfo(role); + const roleInfo = PackageRoles.getRoleInfo(role); const hasStart = !noStartRoles.includes(role); const needsPack = !(roleInfo.output.includes('bundle') || role === 'cli'); const scripts = packageJson.scripts ?? {}; diff --git a/packages/cli/src/commands/migrate/reactRouterDeps.ts b/packages/cli/src/commands/migrate/reactRouterDeps.ts index 3c1ddd293d..1617c855ee 100644 --- a/packages/cli/src/commands/migrate/reactRouterDeps.ts +++ b/packages/cli/src/commands/migrate/reactRouterDeps.ts @@ -16,8 +16,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; -import { PackageGraph } from '../../lib/monorepo'; -import { getRoleFromPackage } from '../../lib/role'; +import { PackageGraph, PackageRoles } from '@backstage/cli-node'; const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom']; const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0'; @@ -27,7 +26,7 @@ export async function command() { await Promise.all( packages.map(async ({ dir, packageJson }) => { - const role = getRoleFromPackage(packageJson); + const role = PackageRoles.getRoleFromPackage(packageJson); if (role === 'frontend') { console.log(`Skipping ${packageJson.name}`); return; diff --git a/packages/cli/src/commands/new/new.ts b/packages/cli/src/commands/new/new.ts index 8aaa671614..61e27af3c5 100644 --- a/packages/cli/src/commands/new/new.ts +++ b/packages/cli/src/commands/new/new.ts @@ -19,10 +19,10 @@ import fs from 'fs-extra'; import { join as joinPath } from 'path'; import { OptionValues } from 'commander'; import { FactoryRegistry } from '../../lib/new/FactoryRegistry'; +import { isMonoRepo } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { assertError } from '@backstage/errors'; import { Task } from '../../lib/tasks'; -import { isMonoRepo } from '../../lib/monorepo/isMonoRepo'; function parseOptions(optionStrings: string[]): Record { const options: Record = {}; diff --git a/packages/cli/src/commands/repo/build.ts b/packages/cli/src/commands/repo/build.ts index d5602a4008..71e58c6201 100644 --- a/packages/cli/src/commands/repo/build.ts +++ b/packages/cli/src/commands/repo/build.ts @@ -18,11 +18,13 @@ import chalk from 'chalk'; import { Command, OptionValues } from 'commander'; import { relative as relativePath } from 'path'; import { buildPackages, getOutputsForRole } from '../../lib/builder'; -import { PackageGraph } from '../../lib/monorepo'; -import { ExtendedPackage } from '../../lib/monorepo/PackageGraph'; -import { runParallelWorkers } from '../../lib/parallel'; import { paths } from '../../lib/paths'; -import { detectRoleFromPackage } from '../../lib/role'; +import { + PackageGraph, + BackstagePackage, + PackageRoles, +} from '@backstage/cli-node'; +import { runParallelWorkers } from '../../lib/parallel'; import { buildFrontend } from '../build/buildFrontend'; import { buildBackend } from '../build/buildBackend'; @@ -93,14 +95,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise { packages = Array.from(withDevDependents).map(name => graph.get(name)!); } - const apps = new Array(); - const backends = new Array(); + const apps = new Array(); + const backends = new Array(); const parseBuildScript = createScriptOptionsParser(cmd, ['package', 'build']); const options = packages.flatMap(pkg => { const role = - pkg.packageJson.backstage?.role ?? detectRoleFromPackage(pkg.packageJson); + pkg.packageJson.backstage?.role ?? + PackageRoles.detectRoleFromPackage(pkg.packageJson); if (!role) { console.warn(`Ignored ${pkg.packageJson.name} because it has no role`); return []; diff --git a/packages/cli/src/commands/repo/clean.ts b/packages/cli/src/commands/repo/clean.ts index 323746e089..13ff061423 100644 --- a/packages/cli/src/commands/repo/clean.ts +++ b/packages/cli/src/commands/repo/clean.ts @@ -18,7 +18,7 @@ import { execFile as execFileCb } from 'child_process'; import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; import { promisify } from 'util'; -import { PackageGraph } from '../../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; const execFile = promisify(execFileCb); diff --git a/packages/cli/src/commands/repo/lint.ts b/packages/cli/src/commands/repo/lint.ts index 8e8ec20ff8..05fbf7b87e 100644 --- a/packages/cli/src/commands/repo/lint.ts +++ b/packages/cli/src/commands/repo/lint.ts @@ -17,11 +17,11 @@ import chalk from 'chalk'; import { OptionValues } from 'commander'; import { relative as relativePath } from 'path'; -import { PackageGraph, ExtendedPackageJSON } from '../../lib/monorepo'; -import { runWorkerQueueThreads } from '../../lib/parallel'; +import { PackageGraph, BackstagePackageJson } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; +import { runWorkerQueueThreads } from '../../lib/parallel'; -function depCount(pkg: ExtendedPackageJSON) { +function depCount(pkg: BackstagePackageJson) { const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0; const devDeps = pkg.devDependencies ? Object.keys(pkg.devDependencies).length diff --git a/packages/cli/src/commands/repo/list-deprecations.ts b/packages/cli/src/commands/repo/list-deprecations.ts index 97fa384561..69fe51f78e 100644 --- a/packages/cli/src/commands/repo/list-deprecations.ts +++ b/packages/cli/src/commands/repo/list-deprecations.ts @@ -18,8 +18,8 @@ import chalk from 'chalk'; import { ESLint } from 'eslint'; import { OptionValues } from 'commander'; import { join as joinPath, relative as relativePath } from 'path'; +import { PackageGraph } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; -import { PackageGraph } from '../../lib/monorepo'; export async function command(opts: OptionValues) { const packages = await PackageGraph.listTargetPackages(); diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 2dd778677a..1ca0da5495 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -16,7 +16,7 @@ import os from 'os'; import { Command, OptionValues } from 'commander'; -import { PackageGraph } from '../../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { runCheck } from '../../lib/run'; diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index 991439c010..e066a40e7a 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -19,10 +19,10 @@ import chalk from 'chalk'; import uniq from 'lodash/uniq'; import { serveBundle } from '../../lib/bundler'; import { loadCliConfig } from '../../lib/config'; -import { paths } from '../../lib/paths'; +import { PackageGraph } from '@backstage/cli-node'; import { Lockfile } from '../../lib/versioning'; import { forbiddenDuplicatesFilter, includedFilter } from '../versions/lint'; -import { PackageGraph } from '../../lib/monorepo'; +import { paths } from '../../lib/paths'; interface StartAppOptions { verifyVersions?: boolean; diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 45b966a52d..997e5f8e60 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -39,7 +39,7 @@ import { ReleaseManifest, } from '@backstage/release-manifests'; import 'global-agent/bootstrap'; -import { PackageGraph } from '../../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; const DEP_TYPES = [ 'dependencies', diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts index 8de38548a9..9466fd0ab7 100644 --- a/packages/cli/src/commands/versions/lint.ts +++ b/packages/cli/src/commands/versions/lint.ts @@ -18,7 +18,7 @@ import { OptionValues } from 'commander'; import { Lockfile } from '../../lib/versioning'; import { paths } from '../../lib/paths'; import partition from 'lodash/partition'; -import { PackageGraph } from '../../lib/monorepo'; +import { PackageGraph } from '@backstage/cli-node'; // Packages that we try to avoid duplicates for const INCLUDED = [/^@backstage\//]; diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 7bd10cfcc3..8035796cba 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -30,9 +30,9 @@ import { RollupOptions, OutputOptions, RollupWarning } from 'rollup'; import { forwardFileImports } from './plugins'; import { BuildOptions, Output } from './types'; import { paths } from '../paths'; +import { BackstagePackageJson } from '@backstage/cli-node'; import { svgrTemplate } from '../svgrTemplate'; -import { ExtendedPackageJSON } from '../monorepo'; -import { readEntryPoints } from '../monorepo/entryPoints'; +import { readEntryPoints } from '../entryPoints'; const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx']; @@ -58,7 +58,7 @@ export async function makeRollupConfigs( let targetPkg = options.packageJson; if (!targetPkg) { const packagePath = resolvePath(targetDir, 'package.json'); - targetPkg = (await fs.readJson(packagePath)) as ExtendedPackageJSON; + targetPkg = (await fs.readJson(packagePath)) as BackstagePackageJson; } const onwarn = ({ code, message }: RollupWarning) => { diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index c2256c8417..780f9c2cfe 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -22,7 +22,7 @@ import { paths } from '../paths'; import { makeRollupConfigs } from './config'; import { BuildOptions, Output } from './types'; import { buildTypeDefinitions } from './buildTypeDefinitions'; -import { getRoleInfo } from '../role'; +import { PackageRoles } from '@backstage/cli-node'; import { runParallelWorkers } from '../parallel'; export function formatErrorMessage(error: any) { @@ -152,7 +152,7 @@ export const buildPackages = async (options: BuildOptions[]) => { export function getOutputsForRole(role: string): Set { const outputs = new Set(); - for (const output of getRoleInfo(role).output) { + for (const output of PackageRoles.getRoleInfo(role).output) { if (output === 'cjs') { outputs.add(Output.cjs); } diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index de7e9b10eb..330419235f 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ExtendedPackageJSON } from '../monorepo'; +import { BackstagePackageJson } from '@backstage/cli-node'; export enum Output { esm, @@ -25,7 +25,7 @@ export enum Output { export type BuildOptions = { logPrefix?: string; targetDir?: string; - packageJson?: ExtendedPackageJSON; + packageJson?: BackstagePackageJson; outputs: Set; minify?: boolean; useApiExtractor?: boolean; diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 6fc7482dce..94b2a1c561 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -32,12 +32,12 @@ import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions } from './types'; import { version } from '../../lib/version'; import { paths as cliPaths } from '../../lib/paths'; +import { BackstagePackage } from '@backstage/cli-node'; import { runPlain } from '../run'; import ESLintPlugin from 'eslint-webpack-plugin'; import pickBy from 'lodash/pickBy'; import yn from 'yn'; -import { readEntryPoints } from '../monorepo/entryPoints'; -import { ExtendedPackage } from '../monorepo'; +import { readEntryPoints } from '../entryPoints'; const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; @@ -235,7 +235,7 @@ export async function createBackendConfig( // Find all local monorepo packages and their node_modules, and mark them as external. const { packages } = await getPackages(cliPaths.targetDir); const localPackageEntryPoints = packages.flatMap(p => { - const entryPoints = readEntryPoints((p as ExtendedPackage).packageJson); + const entryPoints = readEntryPoints((p as BackstagePackage).packageJson); return entryPoints.map(e => posixPath.join(p.packageJson.name, e.mount)); }); const moduleDirs = packages.map(p => resolvePath(p.dir, 'node_modules')); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index a76571c3f6..b34e1280c0 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -23,7 +23,7 @@ import { ConfigReader } from '@backstage/config'; import { paths } from './paths'; import { isValidUrl } from './urls'; import { getPackages } from '@manypkg/get-packages'; -import { PackageGraph } from './monorepo'; +import { PackageGraph } from '@backstage/cli-node'; type Options = { args: string[]; diff --git a/packages/cli/src/lib/monorepo/entryPoints.ts b/packages/cli/src/lib/entryPoints.ts similarity index 93% rename from packages/cli/src/lib/monorepo/entryPoints.ts rename to packages/cli/src/lib/entryPoints.ts index 1e1fd7ed27..1f6e2feec2 100644 --- a/packages/cli/src/lib/monorepo/entryPoints.ts +++ b/packages/cli/src/lib/entryPoints.ts @@ -15,7 +15,7 @@ */ import { extname } from 'path'; -import { ExtendedPackageJSON } from './PackageGraph'; +import { BackstagePackageJson } from '@backstage/cli-node'; export interface EntryPoint { mount: string; @@ -47,7 +47,7 @@ function parseEntryPoint(mount: string, path: string): EntryPoint { return { mount, path, name, ext: extname(path) }; } -export function readEntryPoints(pkg: ExtendedPackageJSON): Array { +export function readEntryPoints(pkg: BackstagePackageJson): Array { const exp = pkg.exports; if (typeof exp === 'string') { return [defaultIndex]; diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts deleted file mode 100644 index c0b50f5140..0000000000 --- a/packages/cli/src/lib/git.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 { assertError, ForwardedError } from '@backstage/errors'; -import { execFile as execFileCb } from 'child_process'; -import { promisify } from 'util'; -import { paths } from './paths'; - -const execFile = promisify(execFileCb); - -/** - * Run a git command, trimming the output splitting it into lines. - */ -export async function runGit(...args: string[]) { - try { - const { stdout } = await execFile('git', args, { - shell: true, - cwd: paths.targetRoot, - }); - return stdout.trim().split(/\r\n|\r|\n/); - } catch (error) { - assertError(error); - if (error.stderr || typeof error.code === 'number') { - const stderr = (error.stderr as undefined | Buffer)?.toString('utf8'); - const msg = stderr?.trim() ?? `with exit code ${error.code}`; - throw new Error(`git ${args[0]} failed, ${msg}`); - } - throw new ForwardedError('Unknown execution error', error); - } -} - -/** - * Returns a sorted list of all files that have changed since the merge base - * of the provided `ref` and HEAD, as well as all files that are not tracked by git. - */ -export async function listChangedFiles(ref: string) { - if (!ref) { - throw new Error('ref is required'); - } - - let diffRef = ref; - try { - const [base] = await runGit('merge-base', 'HEAD', ref); - diffRef = base; - } catch { - // silently fall back to using the ref directly if merge base is not available - } - - const tracked = await runGit('diff', '--name-only', diffRef); - const untracked = await runGit('ls-files', '--others', '--exclude-standard'); - - return Array.from(new Set([...tracked, ...untracked])); -} - -/** - * Returns the contents of a file at a specific ref. - */ -export async function readFileAtRef(path: string, ref: string) { - let showRef = ref; - try { - const [base] = await runGit('merge-base', 'HEAD', ref); - showRef = base; - } catch { - // silently fall back to using the ref directly if merge base is not available - } - - const { stdout } = await execFile('git', ['show', `${showRef}:${path}`], { - shell: true, - cwd: paths.targetRoot, - maxBuffer: 1024 * 1024 * 50, - }); - return stdout; -} diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index 601f442a73..ab8b20919f 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -30,7 +30,6 @@ import { dependencies as cliDependencies, devDependencies as cliDevDependencies, } from '../../../package.json'; -import { PackageGraph, PackageGraphNode } from '../monorepo'; import { BuildOptions, buildPackages, @@ -38,7 +37,11 @@ import { Output, } from '../builder'; import { productionPack } from './productionPack'; -import { getRoleInfo } from '../role'; +import { + PackageRoles, + PackageGraph, + PackageGraphNode, +} from '@backstage/cli-node'; import { runParallelWorkers } from '../parallel'; // These packages aren't safe to pack in parallel since the CLI depends on them @@ -172,7 +175,7 @@ export async function createDistWorkspace( continue; } - if (getRoleInfo(role).output.includes('bundle')) { + if (PackageRoles.getRoleInfo(role).output.includes('bundle')) { console.warn( `Building ${pkg.packageJson.name} separately because it is a bundled package`, ); diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts index 30f4545b24..aea5df649a 100644 --- a/packages/cli/src/lib/packager/productionPack.ts +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -17,8 +17,8 @@ import fs from 'fs-extra'; import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'path'; -import { ExtendedPackageJSON } from '../monorepo'; -import { readEntryPoints } from '../monorepo/entryPoints'; +import { BackstagePackageJson } from '@backstage/cli-node'; +import { readEntryPoints } from '../entryPoints'; const PKG_PATH = 'package.json'; const PKG_BACKUP_PATH = 'package.json-prepack'; @@ -35,7 +35,7 @@ export async function productionPack(options: ProductionPackOptions) { const { packageDir, targetDir } = options; const pkgPath = resolvePath(packageDir, PKG_PATH); const pkgContent = await fs.readFile(pkgPath, 'utf8'); - const pkg = JSON.parse(pkgContent) as ExtendedPackageJSON; + const pkg = JSON.parse(pkgContent) as BackstagePackageJson; // If we're making the update in-line, back up the package.json if (!targetDir) { @@ -147,7 +147,7 @@ function resolveEntrypoint(pkg: any, name: string) { // Writes e.g. alpha/package.json async function writeReleaseStageEntrypoint( - pkg: ExtendedPackageJSON, + pkg: BackstagePackageJson, stage: 'alpha' | 'beta', targetDir: string, ) { @@ -178,7 +178,7 @@ const EXPORT_MAP = { * entry points for importers that don't support exports. */ async function prepareExportsEntryPoints( - pkg: ExtendedPackageJSON, + pkg: BackstagePackageJson, packageDir: string, ) { const distPath = resolvePath(packageDir, 'dist'); diff --git a/packages/cli/src/lib/role.test.ts b/packages/cli/src/lib/role.test.ts new file mode 100644 index 0000000000..adca2d70b8 --- /dev/null +++ b/packages/cli/src/lib/role.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { findRoleFromCommand } from './role'; + +describe('findRoleFromCommand', () => { + function mkCommand(args: string) { + const parsed = new Command() + .option('--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'`); + }); +}); diff --git a/packages/cli/src/lib/role.ts b/packages/cli/src/lib/role.ts new file mode 100644 index 0000000000..49f39f789c --- /dev/null +++ b/packages/cli/src/lib/role.ts @@ -0,0 +1,35 @@ +/* + * 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 fs from 'fs-extra'; +import { OptionValues } from 'commander'; +import { paths } from './paths'; +import { PackageRoles, PackageRole } from '@backstage/cli-node'; + +export async function findRoleFromCommand( + opts: OptionValues, +): Promise { + if (opts.role) { + return PackageRoles.getRoleInfo(opts.role)?.role; + } + + const pkg = await fs.readJson(paths.resolveTarget('package.json')); + const info = PackageRoles.getRoleFromPackage(pkg); + if (!info) { + throw new Error(`Target package must have 'backstage.role' set`); + } + return info; +} diff --git a/packages/cli/src/lib/role/packageRoles.ts b/packages/cli/src/lib/role/packageRoles.ts deleted file mode 100644 index 685848fd26..0000000000 --- a/packages/cli/src/lib/role/packageRoles.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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 { - 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; -} diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 0275c5ee50..909f8be9aa 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; -import { ExtendedPackage } from '../monorepo'; +import { BackstagePackage } from '@backstage/cli-node'; import { Lockfile } from './Lockfile'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. @@ -290,7 +290,7 @@ describe('New Lockfile', () => { 'b', { packageJson: { version: '2.0.1' }, - } as ExtendedPackage, + } as BackstagePackage, ], ]), }); @@ -319,468 +319,4 @@ describe('New Lockfile', () => { 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()], - ]), - ); - }); - }); }); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index 4eadaf55f9..50d0a77cfd 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -18,7 +18,7 @@ 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'; +import { BackstagePackage } from '@backstage/cli-node'; const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; @@ -39,17 +39,6 @@ type LockfileQueryEntry = { 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; @@ -167,7 +156,7 @@ export class Lockfile { /** Analyzes the lockfile to identify possible actions and warnings for the entries */ analyze(options: { filter?: (name: string) => boolean; - localPackages: Map; + localPackages: Map; }): AnalyzeResult { const { filter, localPackages } = options; const result: AnalyzeResult = { @@ -340,84 +329,6 @@ export class Lockfile { } } - createSimplifiedDependencyGraph(): Map> { - const graph = new Map>(); - - 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'); } diff --git a/yarn.lock b/yarn.lock index e8153a59f0..7016647240 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3750,12 +3750,30 @@ __metadata: languageName: unknown linkType: soft +"@backstage/cli-node@workspace:^, @backstage/cli-node@workspace:packages/cli-node": + version: 0.0.0-use.local + resolution: "@backstage/cli-node@workspace:packages/cli-node" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/cli-common": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" + "@manypkg/get-packages": ^1.1.3 + "@yarnpkg/parsers": ^3.0.0-rc.4 + fs-extra: 10.1.0 + mock-fs: ^5.2.0 + semver: ^7.3.2 + zod: ^3.21.4 + languageName: unknown + linkType: soft + "@backstage/cli@workspace:*, @backstage/cli@workspace:^, @backstage/cli@workspace:packages/cli": version: 0.0.0-use.local resolution: "@backstage/cli@workspace:packages/cli" dependencies: "@backstage/backend-common": "workspace:^" "@backstage/cli-common": "workspace:^" + "@backstage/cli-node": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" "@backstage/core-app-api": "workspace:^"