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
+1
View File
@@ -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-lint
title: '@backstage/cli-module-lint'
description: CLI module for Backstage CLI
spec:
lifecycle: experimental
type: backstage-cli-module
owner: tooling-maintainers
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@backstage/cli-module-lint",
"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-lint"
},
"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",
"globby": "^11.0.0",
"shell-quote": "^1.8.1"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/fs-extra": "^11.0.0",
"@types/shell-quote": "^1.7.5"
}
}
@@ -0,0 +1,100 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import { ESLint } from 'eslint';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { fix, format, outputFile, maxWarnings },
_: directories,
} = cli(
{
help: { ...info, usage: `${info.usage} [directories...]` },
booleanFlagNegation: true,
parameters: ['[directories...]'],
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
const eslint = new ESLint({
cwd: targetPaths.dir,
fix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
const results = await eslint.lintFiles(
directories.length ? directories : ['.'],
);
const maxWarningsNum = maxWarnings ? +maxWarnings : -1;
const ignoreWarnings = maxWarningsNum === -1;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarningsNum);
if (fix) {
await ESLint.outputFixes(results);
}
const formatter = await eslint.loadFormatter(format);
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
const resultText = await formatter.format(results);
if (resultText) {
if (outputFile) {
await fs.writeFile(targetPaths.resolve(outputFile), resultText);
} else {
console.log(resultText);
}
}
if (failed) {
process.exit(1);
}
};
@@ -0,0 +1,353 @@
/*
* 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 fs from 'fs-extra';
import { cli } from 'cleye';
import { createHash } from 'node:crypto';
import { relative as relativePath } from 'node:path';
import {
PackageGraph,
BackstagePackageJson,
Lockfile,
runWorkerQueueThreads,
SuccessCache,
} from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CliCommandContext } from '@backstage/cli-node';
function depCount(pkg: BackstagePackageJson) {
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
const devDeps = pkg.devDependencies
? Object.keys(pkg.devDependencies).length
: 0;
return deps + devDeps;
}
export default async ({ args, info }: CliCommandContext) => {
for (const flag of [
'outputFile',
'successCache',
'successCacheDir',
'maxWarnings',
]) {
if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) {
process.stderr.write(
`DEPRECATION WARNING: --${flag} is deprecated, use the kebab-case form instead\n`,
);
}
}
const {
flags: {
fix,
format,
outputFile,
successCache: useSuccessCache,
successCacheDir,
since,
maxWarnings,
},
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
successCache: {
type: Boolean,
description:
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
},
successCacheDir: {
type: String,
description:
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
},
since: {
type: String,
description:
'Only lint packages that changed since the specified ref',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
let packages = await PackageGraph.listTargetPackages();
const cache = SuccessCache.create({
name: 'lint',
basePath: successCacheDir,
});
const cacheContext = useSuccessCache
? {
entries: await cache.read(),
lockfile: await Lockfile.load(targetPaths.resolveRoot('yarn.lock')),
}
: undefined;
if (since) {
const graph = PackageGraph.fromPackages(packages);
packages = await graph.listChangedPackages({
ref: since,
analyzeLockfile: true,
});
}
// Packages are ordered from most to least number of dependencies, as a
// very cheap way of guessing which packages are more likely to be larger.
packages.sort((a, b) => depCount(b.packageJson) - depCount(a.packageJson));
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
// Make sure lint output is colored unless the user explicitly disabled it
if (!process.env.FORCE_COLOR) {
process.env.FORCE_COLOR = '1';
}
const parseLintScript = createScriptOptionsParser(['package', 'lint'], {
fix: { type: 'boolean' },
format: { type: 'string' },
'output-file': { type: 'string' },
'max-warnings': { type: 'string' },
});
const items = await Promise.all(
packages.map(async pkg => {
const lintOptions = parseLintScript(pkg.packageJson.scripts?.lint);
const base = {
fullDir: pkg.dir,
relativeDir: relativePath(targetPaths.rootDir, pkg.dir),
lintOptions,
parentHash: undefined,
};
if (!cacheContext) {
return base;
}
const hash = createHash('sha1');
hash.update(
cacheContext.lockfile.getDependencyTreeHash(pkg.packageJson.name),
);
hash.update('\0');
hash.update(JSON.stringify(lintOptions ?? {}));
hash.update('\0');
hash.update(process.version); // Node.js version
hash.update('\0');
hash.update('v1'); // The version of this implementation
return {
...base,
parentHash: hash.digest('hex'),
};
}),
);
const { results: resultsList } = await runWorkerQueueThreads({
items: items.filter(item => item.lintOptions), // Filter out packages without lint script
context: {
fix: Boolean(fix),
format: format as string | undefined,
shouldCache: Boolean(cacheContext),
maxWarnings: maxWarnings ?? '-1',
successCache: cacheContext?.entries,
rootDir: targetPaths.rootDir,
},
workerFactory: async ({
fix: workerFix,
format: workerFormat,
shouldCache,
successCache,
rootDir,
maxWarnings: workerMaxWarnings,
}) => {
const { ESLint } = require('eslint') as typeof import('eslint');
const crypto = require('node:crypto') as typeof import('crypto');
const globby = require('globby') as typeof import('globby');
const { readFile } =
require('node:fs/promises') as typeof import('fs/promises');
const workerPath = require('node:path') as typeof import('path');
return async ({
fullDir,
relativeDir,
parentHash,
}): Promise<{
relativeDir: string;
sha?: string;
resultText?: string;
failed: boolean;
}> => {
// Bit of a hack to make file resolutions happen from the correct directory
// since some lint rules don't respect the cwd of ESLint
process.cwd = () => fullDir;
const start = Date.now();
const eslint = new ESLint({
cwd: fullDir,
fix: workerFix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
let sha: string | undefined = undefined;
if (shouldCache) {
const result = await globby(relativeDir, {
gitignore: true,
onlyFiles: true,
cwd: rootDir,
});
const hash = crypto.createHash('sha1');
hash.update(parentHash!);
hash.update('\0');
for (const path of result.sort()) {
const absPath = workerPath.resolve(rootDir, path);
const pathInPackage = workerPath.relative(fullDir, absPath);
if (await eslint.isPathIgnored(pathInPackage)) {
continue;
}
hash.update(pathInPackage);
hash.update('\0');
hash.update(await readFile(absPath));
hash.update('\0');
hash.update(
JSON.stringify(
await eslint.calculateConfigForFile(pathInPackage),
).replaceAll(rootDir, ''),
);
hash.update('\0');
}
sha = await hash.digest('hex');
if (successCache?.has(sha)) {
console.log(`Skipped ${relativeDir} due to cache hit`);
return { relativeDir, sha, failed: false };
}
}
const formatter = await eslint.loadFormatter(workerFormat);
const results = await eslint.lintFiles(['.']);
const count = String(results.length).padStart(3);
const time = ((Date.now() - start) / 1000).toFixed(2);
console.log(`Checked ${count} files in ${relativeDir} ${time}s`);
if (workerFix) {
await ESLint.outputFixes(results);
}
const ignoreWarnings = +workerMaxWarnings === -1;
const resultText = formatter.format(results) as string;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
+workerMaxWarnings);
return {
relativeDir,
resultText,
failed,
sha,
};
};
},
});
const outputSuccessCache = [];
const jsonResults = [];
let errorOutput = '';
let failed = false;
for (const {
relativeDir,
resultText,
failed: runFailed,
sha,
} of resultsList) {
if (runFailed) {
console.log(chalk.red(`Lint failed in ${relativeDir}`));
failed = true;
// When doing repo lint, only list the results if the lint failed to avoid a log
// dump of all warnings that might be irrelevant
if (resultText) {
if (outputFile) {
if (format === 'json') {
jsonResults.push(resultText);
} else {
errorOutput += `${resultText}\n`;
}
} else {
console.log();
console.log(resultText.trimStart());
}
}
} else if (sha) {
outputSuccessCache.push(sha);
}
}
if (format === 'json') {
let mergedJsonResults: any[] = [];
for (const jsonResult of jsonResults) {
mergedJsonResults = mergedJsonResults.concat(JSON.parse(jsonResult));
}
errorOutput = JSON.stringify(mergedJsonResults, null, 2);
}
if (outputFile && errorOutput) {
await fs.writeFile(targetPaths.resolveRoot(outputFile), errorOutput);
}
if (cacheContext) {
await cache.write(outputSuccessCache);
}
if (failed) {
process.exit(1);
}
};
+34
View File
@@ -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: ['package', 'lint'],
description: 'Lint a package',
execute: { loader: () => import('./commands/package/lint') },
});
reg.addCommand({
path: ['repo', 'lint'],
description: 'Lint a repository',
execute: { loader: () => import('./commands/repo/lint') },
});
},
});
@@ -0,0 +1,40 @@
/*
* Copyright 2024 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 { parseArgs, type ParseArgsConfig } from 'node:util';
import { parse as parseShellArgs } from 'shell-quote';
export function createScriptOptionsParser(
commandPath: string[],
options: ParseArgsConfig['options'],
) {
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
return undefined;
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr
? parseShellArgs(argsStr).filter(
(e): e is string => typeof e === 'string',
)
: [];
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}