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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user