cli: introduce repo fix command

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-06 20:04:47 +02:00
parent cfa5a343ec
commit 3494c502ab
4 changed files with 194 additions and 101 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added a new `repo fix` command that fixes auto-fixable problems in all packages.
+9
View File
@@ -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')
@@ -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<string, [string]> = {};
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<string, string>
| 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);
}
+166
View File
@@ -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<FixablePackage[]> {
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<void> {
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<string, [string]> = {};
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<string, string>
| 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<void> {
const packages = await readFixablePackages();
for (const pkg of packages) {
fixPackageExports(pkg);
}
if (opts.check) {
if (printPackageFixHint(packages)) {
process.exit(1);
}
} else {
await writeFixedPackages(packages);
}
}