diff --git a/.changeset/fruity-tires-fail.md b/.changeset/fruity-tires-fail.md new file mode 100644 index 0000000000..a0afd0ed7b --- /dev/null +++ b/.changeset/fruity-tires-fail.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added `--include` and `--format` options to `backstage-cli info` command for including additional packages via glob patterns and outputting as JSON or Text. diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index ce8f862c98..07f6dee879 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -432,8 +432,79 @@ Usage: backstage-cli create-github-app Outputs debug information which is useful when opening an issue. Outputs system information, node.js and npm versions, CLI version and type (inside backstage -repo or a created app), all `@backstage/*` package dependency versions. +repo or a created app), all `@backstage/*` package dependency versions, and any +packages that contain a `backstage` field in their `package.json`. + +The command distinguishes between installed packages (from npm) and local +workspace packages, making it easier to understand your Backstage setup. ```text -Usage: backstage-cli info +Usage: backstage-cli info [options] + +Options: + --include Glob patterns for additional packages to include + (e.g., @mycompany/backstage-*) + --format Output format (default: text) + -h, --help display help for command +``` + +### Examples + +Output debug information to the console: + +```bash +yarn backstage-cli info +``` + +Include additional packages matching a glob pattern: + +```bash +yarn backstage-cli info --include "@mycompany/*" +``` + +Output as JSON: + +```bash +yarn backstage-cli info --format json +``` + +Export JSON to a file for further processing: + +```bash +yarn backstage-cli info --format json > backstage-info.json +``` + +Combine options to include custom packages and export to JSON: + +```bash +yarn backstage-cli info --include "@mycompany/backstage-*" --include "@internal/*" --format json > debug-info.json +``` + +Export text output to a file: + +```bash +yarn backstage-cli info --format text > backstage-info.txt +``` + +### JSON Output Format + +When using `--format json`, the output is structured as follows: + +```json +{ + "system": { + "os": "Darwin 23.0.0 - darwin/arm64", + "node": "v18.17.0", + "yarn": "3.6.0", + "cli": { "version": "0.27.0", "local": false }, + "backstage": "1.20.0" + }, + "dependencies": { + "@backstage/core-plugin-api": "1.8.0", + "@backstage/plugin-catalog": "1.15.0" + }, + "local": { + "@mycompany/backstage-plugin-custom": "0.1.0" + } +} ``` diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 26bd89e909..8979e91a82 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -148,7 +148,9 @@ Options: Usage: Options: + --format --help + --include --version ``` diff --git a/packages/cli/src/modules/info/commands/info.ts b/packages/cli/src/modules/info/commands/info.ts index 97090d8b9a..6970699f13 100644 --- a/packages/cli/src/modules/info/commands/info.ts +++ b/packages/cli/src/modules/info/commands/info.ts @@ -19,9 +19,41 @@ import os from 'os'; import { runOutput } from '@backstage/cli-common'; import { paths } from '../../../lib/paths'; import { Lockfile } from '../../../lib/versioning'; +import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node'; +import { minimatch } from 'minimatch'; import fs from 'fs-extra'; -export default async () => { +interface InfoOptions { + include: string[]; + format: 'text' | 'json'; +} + +/** + * Attempts to read package.json from node_modules for a given package name. + * Returns undefined if the package.json cannot be read. + */ +function tryReadPackageJson( + packageName: string, + targetPath: string, +): BackstagePackageJson | undefined { + try { + return require(require.resolve(`${packageName}/package.json`, { + paths: [targetPath], + })); + } catch { + return undefined; + } +} + +/** + * Checks if a package has a backstage field in its package.json + */ +function hasBackstageField(packageName: string, targetPath: string): boolean { + const pkg = tryReadPackageJson(packageName, targetPath); + return pkg?.backstage !== undefined; +} + +export default async (options: InfoOptions) => { await new Promise(async () => { const yarnVersion = await runOutput(['yarn', '--version']); const isLocal = fs.existsSync(paths.resolveOwn('./src')); @@ -33,27 +65,151 @@ export default async () => { const backstageJson = await fs.readJSON(backstageFile); backstageVersion = backstageJson.version ?? 'N/A'; } catch (error) { - console.warn('The "backstage.json" file is not in the expected format'); - console.log(); + if (options.format !== 'json') { + console.warn( + 'The "backstage.json" file is not in the expected format', + ); + console.log(); + } } } - console.log(`OS: ${os.type} ${os.release} - ${os.platform}/${os.arch}`); - console.log(`node: ${process.version}`); - console.log(`yarn: ${yarnVersion}`); + // Build system info + const systemInfo = { + os: `${os.type} ${os.release} - ${os.platform}/${os.arch}`, + node: process.version, + yarn: yarnVersion, + cli: { version: cliVersion, local: isLocal }, + backstage: backstageVersion, + }; + + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); + const targetPath = paths.targetRoot; + + // Get workspace package names and their versions + const workspacePackages = new Map(); + try { + const packages = await PackageGraph.listTargetPackages(); + for (const pkg of packages) { + workspacePackages.set(pkg.packageJson.name, pkg.packageJson.version); + } + } catch { + // If we can't list workspace packages, continue without them + } + + // Collect all package names from lockfile + const allPackages = [...lockfile.keys()]; + const includePatterns = options.include || []; + + // Collect installed (non-local) packages + const installedDeps = new Set(); + // Collect local workspace packages + const localDeps = new Set(); + + // Process @backstage/* packages + for (const pkg of allPackages) { + if (pkg.startsWith('@backstage/')) { + if (workspacePackages.has(pkg)) { + localDeps.add(pkg); + } else { + installedDeps.add(pkg); + } + } + } + + // Process packages matching --include patterns + for (const pattern of includePatterns) { + for (const pkg of allPackages) { + if (minimatch(pkg, pattern)) { + if (workspacePackages.has(pkg)) { + localDeps.add(pkg); + } else { + installedDeps.add(pkg); + } + } + } + } + + // Process packages with backstage field in their package.json + for (const pkg of allPackages) { + // Skip @backstage/* packages (already processed above) + if (pkg.startsWith('@backstage/')) { + continue; + } + if (workspacePackages.has(pkg)) { + // Check if local package has backstage field + if (hasBackstageField(pkg, targetPath)) { + localDeps.add(pkg); + } + } else if (hasBackstageField(pkg, targetPath)) { + installedDeps.add(pkg); + } + } + + // Helper to get versions for a package as an array of objects + const getVersions = (dep: string): Array<{ version: string }> => { + const entries = lockfile.get(dep); + if (!entries) { + return [{ version: 'unknown' }]; + } + const versions = [...new Set(entries.map(i => i.version))]; + return versions.map(v => ({ version: v })); + }; + + const sortedInstalled = [...installedDeps].sort(); + const sortedLocal = [...localDeps].sort(); + + // If format is json, output JSON to stdout + if (options.format === 'json') { + const output = { + system: systemInfo, + dependencies: Object.fromEntries( + sortedInstalled.map(dep => [dep, getVersions(dep)]), + ), + local: Object.fromEntries( + sortedLocal.map(dep => [ + dep, + [{ version: workspacePackages.get(dep) ?? 'unknown' }], + ]), + ), + }; + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + return; + } + + // Print to console + console.log(`OS: ${systemInfo.os}`); + console.log(`node: ${systemInfo.node}`); + console.log(`yarn: ${systemInfo.yarn}`); console.log(`cli: ${cliVersion} (${isLocal ? 'local' : 'installed'})`); console.log(`backstage: ${backstageVersion}`); console.log(); + + // Print installed dependencies console.log('Dependencies:'); - const lockfilePath = paths.resolveTargetRoot('yarn.lock'); - const lockfile = await Lockfile.load(lockfilePath); + if (sortedInstalled.length > 0) { + const maxLength = Math.max(...sortedInstalled.map(d => d.length)); + for (const dep of sortedInstalled) { + const versions = getVersions(dep) + .map(v => v.version) + .join(', '); + console.log(` ${dep.padEnd(maxLength)} ${versions}`); + } + } else { + console.log(' (no installed Backstage packages found)'); + } - const deps = [...lockfile.keys()].filter(n => n.startsWith('@backstage/')); - const maxLength = Math.max(...deps.map(d => d.length)); - - for (const dep of deps) { - const versions = new Set(lockfile.get(dep)!.map(i => i.version)); - console.log(` ${dep.padEnd(maxLength)} ${[...versions].join(', ')}`); + // Print local workspace packages + if (localDeps.size > 0) { + console.log(); + console.log('Local:'); + const maxLength = Math.max(...sortedLocal.map(d => d.length)); + for (const dep of sortedLocal) { + const version = workspacePackages.get(dep) ?? 'unknown'; + console.log(` ${dep.padEnd(maxLength)} ${version}`); + } } }); }; diff --git a/packages/cli/src/modules/info/index.ts b/packages/cli/src/modules/info/index.ts index e93f008905..c14926df85 100644 --- a/packages/cli/src/modules/info/index.ts +++ b/packages/cli/src/modules/info/index.ts @@ -24,8 +24,25 @@ export default createCliPlugin({ path: ['info'], description: 'Show helpful information for debugging and reporting bugs', execute: async ({ args }) => { - yargs().parse(args); - await lazy(() => import('./commands/info'), 'default')(args); + const argv = await yargs() + .options({ + include: { + type: 'string', + array: true, + default: [], + description: + 'Glob patterns for additional packages to include (e.g., @spotify/backstage*)', + }, + format: { + type: 'string', + choices: ['text', 'json'], + default: 'text', + description: 'Output format (text or json)', + }, + }) + .help() + .parse(args); + await lazy(() => import('./commands/info'), 'default')(argv); }, }); },