From 3494c502aba749935790f3e3737d6b1f892255cd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 6 Sep 2023 20:04:47 +0200 Subject: [PATCH] cli: introduce repo fix command Signed-off-by: Patrik Oldsberg --- .changeset/funny-timers-retire.md | 5 + packages/cli/src/commands/index.ts | 9 + .../src/commands/migrate/packageExports.ts | 115 ++---------- packages/cli/src/commands/repo/fix.ts | 166 ++++++++++++++++++ 4 files changed, 194 insertions(+), 101 deletions(-) create mode 100644 .changeset/funny-timers-retire.md create mode 100644 packages/cli/src/commands/repo/fix.ts diff --git a/.changeset/funny-timers-retire.md b/.changeset/funny-timers-retire.md new file mode 100644 index 0000000000..cee28b5b12 --- /dev/null +++ b/.changeset/funny-timers-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added a new `repo fix` command that fixes auto-fixable problems in all packages. diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a7f65588a5..b1941ddac8 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -67,6 +67,15 @@ export function registerRepoCommand(program: Command) { .option('--fix', 'Attempt to automatically fix violations') .action(lazy(() => import('./repo/lint').then(m => m.command))); + command + .command('fix') + .description('Automatically fix packages in the project') + .option( + '--check', + 'Fail if any packages would have been changed by the command', + ) + .action(lazy(() => import('./repo/fix').then(m => m.command))); + command .command('clean') .description('Delete cache and output directories') diff --git a/packages/cli/src/commands/migrate/packageExports.ts b/packages/cli/src/commands/migrate/packageExports.ts index 82d0b73ac2..6757d01bbe 100644 --- a/packages/cli/src/commands/migrate/packageExports.ts +++ b/packages/cli/src/commands/migrate/packageExports.ts @@ -14,108 +14,21 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { resolve as resolvePath } from 'path'; -import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; - -function trimRelative(path: string): string { - if (path.startsWith('./')) { - return path.slice(2); - } - return path; -} +import { + fixPackageExports, + readFixablePackages, + writeFixedPackages, +} from '../repo/fix'; export async function command() { - const packages = await PackageGraph.listTargetPackages(); - - await Promise.all( - packages.map(async ({ dir, packageJson }) => { - let changed = false; - let newPackageJson = packageJson; - - let { exports: exp } = newPackageJson; - if (!exp) { - return; - } - if (Array.isArray(exp)) { - throw new Error('Unexpected array in package.json exports field'); - } - - // If exports is a string we rewrite it to an object to add package.json - if (typeof exp === 'string') { - changed = true; - exp = { '.': exp }; - newPackageJson.exports = exp; - } else if (typeof exp !== 'object') { - return; - } - - if (!exp['./package.json']) { - changed = true; - exp['./package.json'] = './package.json'; - } - - const existingTypesVersions = JSON.stringify(packageJson.typesVersions); - - const typeEntries: Record = {}; - for (const [path, value] of Object.entries(exp)) { - // Main entry point does not need to be listed - if (path === '.') { - continue; - } - const newPath = trimRelative(path); - - if (typeof value === 'string') { - typeEntries[newPath] = [trimRelative(value)]; - } else if ( - value && - typeof value === 'object' && - !Array.isArray(value) - ) { - if (typeof value.types === 'string') { - typeEntries[newPath] = [trimRelative(value.types)]; - } else if (typeof value.default === 'string') { - typeEntries[newPath] = [trimRelative(value.default)]; - } - } - } - - const typesVersions = { '*': typeEntries }; - if (existingTypesVersions !== JSON.stringify(typesVersions)) { - console.log(`Synchronizing exports in ${packageJson.name}`); - const newPkgEntries = Object.entries(newPackageJson).filter( - ([name]) => name !== 'typesVersions', - ); - newPkgEntries.splice( - newPkgEntries.findIndex(([name]) => name === 'exports') + 1, - 0, - ['typesVersions', typesVersions], - ); - - newPackageJson = Object.fromEntries( - newPkgEntries, - ) as BackstagePackageJson; - changed = true; - } - - // Remove the legacy fields from publishConfig, which are no longer needed - const publishConfig = newPackageJson.publishConfig as - | Record - | undefined; - if (publishConfig) { - for (const field of ['main', 'module', 'browser', 'types']) { - if (publishConfig[field]) { - delete publishConfig[field]; - changed = true; - } - } - } - - if (changed) { - await fs.writeJson(resolvePath(dir, 'package.json'), newPackageJson, { - spaces: 2, - }); - } - }), + console.log( + 'The `migrate package-exports` command is deprecated, use `repo fix` instead.', ); + const packages = await readFixablePackages(); + + for (const pkg of packages) { + fixPackageExports(pkg); + } + + await writeFixedPackages(packages); } diff --git a/packages/cli/src/commands/repo/fix.ts b/packages/cli/src/commands/repo/fix.ts new file mode 100644 index 0000000000..2d46897d52 --- /dev/null +++ b/packages/cli/src/commands/repo/fix.ts @@ -0,0 +1,166 @@ +/* + * 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 { + BackstagePackage, + BackstagePackageJson, + PackageGraph, +} from '@backstage/cli-node'; +import { OptionValues } from 'commander'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; + +/** + * A mutable object representing a package.json file with potential fixes. + */ +export interface FixablePackage extends BackstagePackage { + changed: boolean; +} + +export async function readFixablePackages(): Promise { + const packages = await PackageGraph.listTargetPackages(); + return packages.map(pkg => ({ ...pkg, changed: false })); +} + +export function printPackageFixHint(packages: FixablePackage[]) { + const changed = packages.filter(pkg => pkg.changed); + if (changed.length > 0) { + console.log( + 'The following packages are out of sync, run `yarn fix` to fix them:', + ); + for (const pkg of changed) { + console.log(` ${pkg.packageJson.name}`); + } + return true; + } + return false; +} + +export async function writeFixedPackages( + packages: FixablePackage[], +): Promise { + await Promise.all( + packages.map(async pkg => { + if (!pkg.changed) { + return; + } + + await fs.writeJson( + resolvePath(pkg.dir, 'package.json'), + pkg.packageJson, + { + spaces: 2, + }, + ); + }), + ); +} + +function trimRelative(path: string): string { + if (path.startsWith('./')) { + return path.slice(2); + } + return path; +} + +export function fixPackageExports(pkg: FixablePackage) { + let { exports: exp } = pkg.packageJson; + if (!exp) { + return; + } + if (Array.isArray(exp)) { + throw new Error('Unexpected array in package.json exports field'); + } + + // If exports is a string we rewrite it to an object to add package.json + if (typeof exp === 'string') { + pkg.changed = true; + exp = { '.': exp }; + pkg.packageJson.exports = exp; + } else if (typeof exp !== 'object') { + return; + } + + if (!exp['./package.json']) { + pkg.changed = true; + exp['./package.json'] = './package.json'; + } + + const existingTypesVersions = JSON.stringify(pkg.packageJson.typesVersions); + + const typeEntries: Record = {}; + for (const [path, value] of Object.entries(exp)) { + // Main entry point does not need to be listed + if (path === '.') { + continue; + } + const newPath = trimRelative(path); + + if (typeof value === 'string') { + typeEntries[newPath] = [trimRelative(value)]; + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + if (typeof value.types === 'string') { + typeEntries[newPath] = [trimRelative(value.types)]; + } else if (typeof value.default === 'string') { + typeEntries[newPath] = [trimRelative(value.default)]; + } + } + } + + const typesVersions = { '*': typeEntries }; + if (existingTypesVersions !== JSON.stringify(typesVersions)) { + const newPkgEntries = Object.entries(pkg.packageJson).filter( + ([name]) => name !== 'typesVersions', + ); + newPkgEntries.splice( + newPkgEntries.findIndex(([name]) => name === 'exports') + 1, + 0, + ['typesVersions', typesVersions], + ); + + pkg.packageJson = Object.fromEntries(newPkgEntries) as BackstagePackageJson; + pkg.changed = true; + } + + // Remove the legacy fields from publishConfig, which are no longer needed + const publishConfig = pkg.packageJson.publishConfig as + | Record + | undefined; + if (publishConfig) { + for (const field of ['main', 'module', 'browser', 'types']) { + if (publishConfig[field]) { + delete publishConfig[field]; + pkg.changed = true; + } + } + } +} + +export async function command(opts: OptionValues): Promise { + const packages = await readFixablePackages(); + + for (const pkg of packages) { + fixPackageExports(pkg); + } + + if (opts.check) { + if (printPackageFixHint(packages)) { + process.exit(1); + } + } else { + await writeFixedPackages(packages); + } +}