Split CLI modules into separate packages

Extract each CLI module from packages/cli/src/modules/ into its own
package under packages/cli-module-*. This enables independent versioning
and clearer dependency boundaries for each CLI capability.

Module mapping:
- auth → @backstage/cli-module-auth
- build → @backstage/cli-module-build
- config → @backstage/cli-module-config
- create-github-app → @backstage/cli-module-create-github-app
- info → @backstage/cli-module-info
- lint → @backstage/cli-module-lint
- maintenance → @backstage/cli-module-maintenance
- migrate → @backstage/cli-module-migrate
- new → @backstage/cli-module-new
- test → @backstage/cli-module-test-jest
- translations → @backstage/cli-module-translations

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-13 13:43:02 +01:00
parent 18012b5802
commit a151ad0814
251 changed files with 1327 additions and 339 deletions
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-cli-module-maintenance
title: '@backstage/cli-module-maintenance'
description: CLI module for Backstage CLI
spec:
lifecycle: experimental
type: backstage-cli-module
owner: tooling-maintainers
@@ -0,0 +1,45 @@
{
"name": "@backstage/cli-module-maintenance",
"version": "0.1.0",
"description": "CLI module for Backstage CLI",
"backstage": {
"role": "cli-module"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/cli-module-maintenance"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/cli-common": "workspace:^",
"@backstage/cli-node": "workspace:^",
"chalk": "^4.0.0",
"cleye": "^2.3.0",
"eslint": "^8.6.0",
"fs-extra": "^11.2.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/fs-extra": "^11.0.0"
}
}
@@ -0,0 +1,559 @@
/*
* 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,
PackageRole,
PackageRoles,
} from '@backstage/cli-node';
import { cli } from 'cleye';
import fs from 'fs-extra';
import {
resolve as resolvePath,
posix,
relative as relativePath,
extname,
} from 'node:path';
import { targetPaths } from '@backstage/cli-common';
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
/**
* 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) {
const rootPkg = require(targetPaths.resolveRoot('package.json'));
const fixCmd =
rootPkg.scripts?.fix === 'backstage-cli repo fix'
? 'fix'
: 'backstage-cli repo fix';
console.log(
`The following packages are out of sync, run 'yarn ${fixCmd}' 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);
// Only script files and package.json should be added to typesVersions
if (typeof value === 'string') {
if (SCRIPT_EXTS.includes(extname(value))) {
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' &&
SCRIPT_EXTS.includes(extname(value.default))
) {
typeEntries[newPath] = [trimRelative(value.default)];
}
}
}
const hasTypeEntries = Object.keys(typeEntries).length > 0;
const typesVersions = hasTypeEntries ? { '*': typeEntries } : undefined;
if (existingTypesVersions !== JSON.stringify(typesVersions)) {
if (pkg.packageJson.typesVersions) {
// Update in place to preserve field order
if (typesVersions) {
pkg.packageJson.typesVersions = typesVersions;
} else {
delete pkg.packageJson.typesVersions;
}
} else if (typesVersions) {
// Insert after exports when adding for the first time
const newPkgEntries = Object.entries(pkg.packageJson);
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 function fixSideEffects(pkg: FixablePackage) {
const role = PackageRoles.getRoleFromPackage(pkg.packageJson);
if (!role) {
return;
}
const roleInfo = PackageRoles.getRoleInfo(role);
// Only web and common packages benefit from being marked as side effect free
if (roleInfo.platform === 'node') {
return;
}
// Bundled packages don't need to mark themselves as having no side effects
if (roleInfo.output.length === 1 && roleInfo.output[0] === 'bundle') {
return;
}
// Already declared
if ('sideEffects' in pkg.packageJson) {
return;
}
const pkgEntries = Object.entries(pkg.packageJson);
pkgEntries.splice(
// Place it just above the scripts field
pkgEntries.findIndex(([name]) => name === 'scripts'),
0,
['sideEffects', false],
);
pkg.packageJson = Object.fromEntries(pkgEntries) as BackstagePackageJson;
pkg.changed = true;
}
export function createRepositoryFieldFixer() {
const rootPkg = require(targetPaths.resolveRoot('package.json'));
const rootRepoField = rootPkg.repository;
if (!rootRepoField) {
return () => {};
}
const rootType = rootRepoField.type || 'git';
const rootUrl = rootRepoField.url;
const rootDir = rootRepoField.directory || '';
return (pkg: FixablePackage) => {
const expectedPath = posix.join(
rootDir,
relativePath(targetPaths.rootDir, pkg.dir),
);
const repoField = pkg.packageJson.repository;
if (!repoField || typeof repoField === 'string') {
const pkgEntries = Object.entries(pkg.packageJson);
pkgEntries.splice(
// Place it just above the backstage field
pkgEntries.findIndex(([name]) => name === 'backstage'),
0,
[
'repository',
{
type: rootType,
url: rootUrl,
directory: expectedPath,
},
],
);
pkg.packageJson = Object.fromEntries(pkgEntries) as BackstagePackageJson;
pkg.changed = true;
return;
}
// If there's a type or URL mismatch, leave the field as is
if (repoField.type !== rootType || repoField.url !== rootUrl) {
return;
}
if (repoField.directory !== expectedPath) {
repoField.directory = expectedPath;
pkg.changed = true;
}
};
}
function guessPluginId(role: PackageRole, pkgName: string): string | undefined {
switch (role) {
case 'frontend':
case 'frontend-plugin':
return pkgName.match(/plugin-(.*)/)?.[1];
case 'frontend-plugin-module':
return pkgName.match(/plugin-(.*)-module-/)?.[1];
case 'backend-plugin':
return pkgName.match(/plugin-(.*)-backend$/)?.[1];
case 'backend-plugin-module':
return pkgName.match(/plugin-(.*)-backend-module-/)?.[1];
case 'common-library':
return pkgName.match(/plugin-(.*)-(?:common)$/)?.[1];
case 'web-library':
return pkgName.match(/plugin-(.*)-(?:react|test-utils)/)?.[1];
case 'node-library':
return pkgName.match(/plugin-(.*)-(?:node|backend)-?/)?.[1];
default:
throw new Error(
`Invalid 'backstage.role' field in "${pkgName}", got '${role}'`,
);
}
}
export function fixPluginId(pkg: FixablePackage) {
const role = pkg.packageJson.backstage?.role;
if (!role) {
return;
}
if (
role === 'backend' ||
role === 'frontend' ||
role === 'cli' ||
role === 'cli-module'
) {
return;
}
const currentId = pkg.packageJson.backstage?.pluginId;
if (currentId !== undefined) {
if (typeof currentId !== 'string' && currentId !== null) {
throw new Error(
`Invalid 'backstage.pluginId' field in "${pkg.packageJson.name}", must be a string`,
);
}
return;
}
const guessedPluginId = guessPluginId(role, pkg.packageJson.name);
if (
!guessedPluginId &&
(role === 'frontend-plugin' ||
role === 'frontend-plugin-module' ||
role === 'backend-plugin' ||
role === 'backend-plugin-module')
) {
const path = relativePath(
targetPaths.rootDir,
resolvePath(pkg.dir, 'package.json'),
);
const msg = `Failed to guess plugin ID for "${pkg.packageJson.name}", please set the 'backstage.pluginId' field manually in "${path}"`;
if (role.endsWith('module')) {
const suggestedRole = role.startsWith('frontend')
? 'web-library'
: 'node-library';
throw new Error(
`${msg}. It is also possible that this package is not a module, and should use the '${suggestedRole}' role instead.`,
);
} else {
throw new Error(msg);
}
}
if (guessedPluginId) {
pkg.packageJson.backstage = {
...pkg.packageJson.backstage,
pluginId: guessedPluginId,
};
pkg.changed = true;
}
}
const backendPluginPackageNameByPluginId = new Map(
[
'app',
'auth',
'catalog',
'events',
'kubernetes',
'notifications',
'permission',
'scaffolder',
'search',
'signals',
'techdocs',
].map(pluginId => [pluginId, `@backstage/plugin-${pluginId}-backend`]),
);
const pluginPackageRoles: Array<string | undefined> = [
'frontend-plugin',
'backend-plugin',
'common-library',
'web-library',
'node-library',
];
export function fixPluginPackages(
pkg: FixablePackage,
repoPackages: FixablePackage[],
) {
const pkgBackstage = pkg.packageJson.backstage;
const role = pkgBackstage?.role;
if (!role) {
return;
}
if (
role === 'backend' ||
role === 'frontend' ||
role === 'cli' ||
role === 'cli-module'
) {
return;
}
const pluginId = pkgBackstage.pluginId;
if (!pluginId) {
// Might be a plugin-less library, skip
if (
role === 'common-library' ||
role === 'web-library' ||
role === 'node-library' ||
role === 'frontend-plugin-module' // TODO(Rugvip): Remove this once frontend modules are required to have a plugin ID
) {
return;
}
throw new Error(
`Missing 'backstage.pluginId' field in "${pkg.packageJson.name}"`,
);
}
if (role === 'backend-plugin-module' || role === 'frontend-plugin-module') {
const targetRole = role.replace('-module', '');
// Try to find a plugin package in the same repo, but otherwise fall back to looking up the package name by ID of @backstage/* plugins
const pluginPkgName =
repoPackages.find(
p =>
p.packageJson.backstage?.pluginId === pluginId &&
p.packageJson.backstage?.role === targetRole,
)?.packageJson.name ?? backendPluginPackageNameByPluginId.get(pluginId);
if (!pluginPkgName) {
// If we can't find a matching package in the repo but one is declared, skip
if (pkgBackstage.pluginPackage) {
return;
}
const path = relativePath(
targetPaths.rootDir,
resolvePath(pkg.dir, 'package.json'),
);
const suggestedRole =
role === 'frontend-plugin-module' ? 'web-library' : 'node-library';
throw new Error(
`Failed to find plugin package for "${pkg.packageJson.name}", please declare the name of the plugin package that this package is a module for in the 'backstage.pluginPackage' field in "${path}". ` +
`It is also possible that this package is not a module, and should use the '${suggestedRole}' role instead.`,
);
}
if (pkgBackstage.pluginPackage !== pluginPkgName) {
pkgBackstage.pluginPackage = pluginPkgName;
pkg.changed = true;
}
} else {
let pluginPackages: string[] | undefined = repoPackages
.filter(
p =>
p.packageJson.backstage?.pluginId === pluginId &&
pluginPackageRoles.includes(p.packageJson.backstage?.role),
)
.map(p => p.packageJson.name)
.sort();
if (pluginPackages.length === 0) {
pluginPackages = undefined;
}
if (pkgBackstage.pluginPackages?.join(',') !== pluginPackages?.join(',')) {
pkgBackstage.pluginPackages = pluginPackages;
pkg.changed = true;
}
}
}
export function fixPeerModules(pkg: FixablePackage) {
const pkgBackstage = pkg.packageJson.backstage;
const role = pkgBackstage?.role;
if (!role) {
return;
}
const peerModules = pkgBackstage.peerModules;
if (peerModules === undefined) {
return;
}
const packagePath = relativePath(
targetPaths.rootDir,
resolvePath(pkg.dir, 'package.json'),
);
// Validate that peerModules is only used on plugin packages
if (role !== 'backend-plugin' && role !== 'frontend-plugin') {
throw new Error(
`The 'backstage.peerModules' field in "${pkg.packageJson.name}" can only be used on plugin packages (backend-plugin or frontend-plugin), but package has role '${role}' in "${packagePath}"`,
);
}
// Validate that peerModules is an array
if (!Array.isArray(peerModules)) {
throw new Error(
`Invalid 'backstage.peerModules' field in "${pkg.packageJson.name}", must be an array of package names in "${packagePath}"`,
);
}
// Validate that all entries are non-empty strings
for (const entry of peerModules) {
if (typeof entry !== 'string' || entry.length === 0) {
throw new Error(
`Invalid entry in 'backstage.peerModules' field in "${pkg.packageJson.name}", all entries must be non-empty package name strings in "${packagePath}"`,
);
}
}
}
type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void;
export default async ({
args,
info,
}: import('@backstage/cli-node').CliCommandContext) => {
const {
flags: { publish, check },
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
publish: {
type: Boolean,
description:
'Enable additional fixes that only apply when publishing packages',
},
check: {
type: Boolean,
description:
'Fail if any packages would have been changed by the command',
},
},
},
undefined,
args,
);
const packages = await readFixablePackages();
const fixRepositoryField = createRepositoryFieldFixer();
const fixers: PackageFixer[] = [fixPackageExports, fixSideEffects];
if (publish) {
fixers.push(
fixRepositoryField,
fixPluginId,
fixPluginPackages,
fixPeerModules,
);
}
for (const fixer of fixers) {
for (const pkg of packages) {
fixer(pkg, packages);
}
}
if (check) {
if (printPackageFixHint(packages)) {
process.exit(1);
}
} else {
await writeFixedPackages(packages);
}
};
@@ -0,0 +1,105 @@
/*
* 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 chalk from 'chalk';
import { ESLint } from 'eslint';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { json },
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
json: { type: Boolean, description: 'Output as JSON' },
},
},
undefined,
args,
);
const packages = await PackageGraph.listTargetPackages();
const eslint = new ESLint({
cwd: targetPaths.dir,
overrideConfig: {
plugins: ['deprecation'],
rules: {
'deprecation/deprecation': 'error',
},
parserOptions: {
project: [targetPaths.resolveRoot('tsconfig.json')],
},
},
extensions: ['jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
const { stderr } = process;
if (stderr.isTTY) {
stderr.write('Initializing TypeScript...');
}
const deprecations = [];
for (const [index, pkg] of packages.entries()) {
const results = await eslint.lintFiles(pkg.dir);
for (const result of results) {
for (const message of result.messages) {
if (message.ruleId !== 'deprecation/deprecation') {
continue;
}
const path = relativePath(targetPaths.rootDir, result.filePath);
deprecations.push({
path,
message: message.message,
line: message.line,
column: message.column,
});
}
}
if (stderr.isTTY) {
stderr.clearLine(0);
stderr.cursorTo(0);
stderr.write(`Scanning packages ${index + 1}/${packages.length}`);
}
}
if (stderr.isTTY) {
stderr.clearLine(0);
stderr.cursorTo(0);
}
if (json) {
console.log(JSON.stringify(deprecations, null, 2));
} else {
for (const d of deprecations) {
const location = `${d.path}:${d.line}:${d.column}`;
const wrappedMessage = d.message.replace(/\r?\n\s*/g, ' ');
console.log(`${location} - ${chalk.yellow(wrappedMessage)}`);
}
}
if (deprecations.length > 0) {
process.exit(1);
}
};
@@ -0,0 +1,34 @@
/*
* Copyright 2025 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 { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['repo', 'fix'],
description: 'Automatically fix packages in the project',
execute: { loader: () => import('./commands/repo/fix') },
});
reg.addCommand({
path: ['repo', 'list-deprecations'],
description: 'List deprecations',
execute: { loader: () => import('./commands/repo/list-deprecations') },
});
},
});