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-migrate
title: '@backstage/cli-module-migrate'
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-migrate",
"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-migrate"
},
"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:^",
"@manypkg/get-packages": "^1.1.3",
"cleye": "^2.3.0",
"fs-extra": "^11.2.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@types/fs-extra": "^11.0.0",
"msw": "^1.0.0"
}
}
@@ -0,0 +1,25 @@
/*
* 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 { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
throw new Error(
'The `migrate package-exports` command has been removed, use `repo fix` instead.',
);
};
@@ -0,0 +1,92 @@
/*
* 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { runOutput } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`;
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
const oldConfigs = [
require.resolve('@backstage/cli/config/eslint.js'),
require.resolve('@backstage/cli/config/eslint.backend.js'),
];
const configPaths = new Array<string>();
await Promise.all(
packages.map(async ({ dir, packageJson }) => {
const configPath = resolvePath(dir, '.eslintrc.js');
if (!(await fs.pathExists(configPath))) {
console.log(`Skipping ${packageJson.name}, missing .eslintrc.js`);
return;
}
let existingConfig: Record<string, unknown>;
try {
existingConfig = require(configPath);
} catch (error) {
console.log(
`Skipping ${packageJson.name}, failed to load .eslintrc.js, ${error}`,
);
return;
}
// Only transform configs that extend the old config files, and
// we remove that entry from the extends array.
const extendsArray = (existingConfig.extends as string[]) ?? [];
const extendIndex = extendsArray.findIndex(p => oldConfigs.includes(p));
if (extendIndex === -1) {
console.log(
`Skipping ${packageJson.name}, .eslintrc.js does not extend the legacy config`,
);
return;
}
extendsArray.splice(extendIndex, 1);
if (extendsArray.length === 0) {
delete existingConfig.extends;
}
if (Object.keys(existingConfig).length > 0) {
await fs.writeFile(
configPath,
`${PREFIX}(__dirname, ${JSON.stringify(existingConfig, null, 2)});\n`,
);
} else {
await fs.writeFile(configPath, `${PREFIX}(__dirname);\n`);
}
configPaths.push(configPath);
}),
);
// If prettier is present, then we run that too
let hasPrettier = false;
try {
require.resolve('prettier');
hasPrettier = true;
} catch {
/* ignore */
}
if (hasPrettier) {
await runOutput(['prettier', '--write', ...configPaths]);
}
};
@@ -0,0 +1,72 @@
/*
* 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { getPackages } from '@manypkg/get-packages';
import { PackageRoles } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
const { packages } = await getPackages(targetPaths.dir);
await Promise.all(
packages.map(async ({ dir, packageJson: pkg }) => {
const { name } = pkg;
const existingRole = PackageRoles.getRoleFromPackage(pkg);
if (existingRole) {
return;
}
const detectedRole = PackageRoles.detectRoleFromPackage(pkg);
if (!detectedRole) {
console.error(`No role detected for package ${name}`);
return;
}
console.log(`Detected package role of ${name} as ${detectedRole}`);
let newPkg = pkg as any;
const pkgKeys = Object.keys(pkg);
if (pkgKeys.includes('backstage')) {
newPkg.backstage = {
...newPkg.backstage,
role: detectedRole,
};
} else {
// We insert the backstage field after one of these fields, otherwise at the end
const index =
Math.max(
pkgKeys.indexOf('version'),
pkgKeys.indexOf('private'),
pkgKeys.indexOf('publishConfig'),
) + 1 || pkgKeys.length;
const pkgEntries = Object.entries(pkg);
pkgEntries.splice(index, 0, ['backstage', { role: detectedRole }]);
newPkg = Object.fromEntries(pkgEntries);
}
await fs.writeJson(resolvePath(dir, 'package.json'), newPkg, {
spaces: 2,
});
}),
);
};
@@ -0,0 +1,110 @@
/*
* 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph, PackageRoles, PackageRole } from '@backstage/cli-node';
import type { CliCommandContext } from '@backstage/cli-node';
const configArgPattern = /--config[=\s][^\s$]+/;
const noStartRoles: PackageRole[] = ['cli', 'cli-module', 'common-library'];
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await Promise.all(
packages.map(async ({ dir, packageJson }) => {
const role = PackageRoles.getRoleFromPackage(packageJson);
if (!role) {
return;
}
const roleInfo = PackageRoles.getRoleInfo(role);
const hasStart = !noStartRoles.includes(role);
const needsPack = !(roleInfo.output.includes('bundle') || role === 'cli');
const scripts = packageJson.scripts ?? {};
const startCmd = ['start'];
if (scripts.start?.includes('--check')) {
startCmd.push('--check');
}
if (scripts.start?.includes('--config')) {
startCmd.push(...(scripts.start.match(configArgPattern) ?? []));
}
const buildCmd = ['build'];
if (scripts.build?.includes('--minify')) {
buildCmd.push('--minify');
}
if (scripts.build?.includes('--config')) {
buildCmd.push(...(scripts.build.match(configArgPattern) ?? []));
}
// For test scripts we keep all existing flags except for --passWithNoTests, since that's now default
const testCmd = ['test'];
if (scripts.test?.startsWith('backstage-cli test')) {
const testArgs = scripts.test
.slice('backstage-cli test'.length)
.split(' ')
.filter(Boolean);
if (testArgs.includes('--passWithNoTests')) {
testArgs.splice(testArgs.indexOf('--passWithNoTests'), 1);
}
testCmd.push(...testArgs);
}
const expectedScripts = {
...(hasStart && {
start: `backstage-cli package ${startCmd.join(' ')}`,
}),
build: `backstage-cli package ${buildCmd.join(' ')}`,
lint: 'backstage-cli package lint',
test: `backstage-cli package ${testCmd.join(' ')}`,
clean: 'backstage-cli package clean',
...(needsPack && {
postpack: 'backstage-cli package postpack',
prepack: 'backstage-cli package prepack',
}),
};
let changed = false;
const currentScripts: Record<string, string | undefined> =
(packageJson.scripts = packageJson.scripts || {});
for (const [name, value] of Object.entries(expectedScripts)) {
const currentScript = currentScripts[name];
const isMissing = !currentScript;
const isDifferent = currentScript !== value;
const isBackstageScript = currentScript?.includes('backstage-cli');
if (isMissing || (isDifferent && isBackstageScript)) {
changed = true;
currentScripts[name] = value;
}
}
if (changed) {
console.log(`Updating scripts for ${packageJson.name}`);
await fs.writeJson(resolvePath(dir, 'package.json'), packageJson, {
spaces: 2,
});
}
}),
);
};
@@ -0,0 +1,62 @@
/*
* 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
import type { CliCommandContext } from '@backstage/cli-node';
const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom'];
const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await Promise.all(
packages.map(async ({ dir, packageJson }) => {
const role = PackageRoles.getRoleFromPackage(packageJson);
if (role === 'frontend') {
console.log(`Skipping ${packageJson.name}`);
return;
}
let changed = false;
for (const depName of ['dependencies', 'devDependencies'] as const) {
const depsCollection = packageJson[depName];
if (depsCollection) {
for (const key of Object.keys(depsCollection)) {
if (REACT_ROUTER_DEPS.includes(key)) {
delete depsCollection[key];
const peerDeps = (packageJson.peerDependencies =
packageJson.peerDependencies ?? {});
peerDeps[key] = REACT_ROUTER_RANGE;
changed = true;
}
}
}
}
if (changed) {
console.log(`Updating dependencies for ${packageJson.name}`);
await fs.writeJson(resolvePath(dir, 'package.json'), packageJson, {
spaces: 2,
});
}
}),
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
/*
* 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 {
BACKSTAGE_JSON,
bootstrapEnvProxyAgents,
targetPaths,
} from '@backstage/cli-common';
bootstrapEnvProxyAgents();
import { env } from 'node:process';
import fs from 'fs-extra';
import chalk from 'chalk';
import { minimatch } from 'minimatch';
import semver from 'semver';
import { cli } from 'cleye';
import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'node:path';
import {
hasBackstageYarnPlugin,
Lockfile,
runConcurrentTasks,
} from '@backstage/cli-node';
import {
fetchPackageInfo,
mapDependencies,
YarnInfoInspectData,
} from '../../lib/versioning/packages';
import {
getManifestByReleaseLine,
getManifestByVersion,
ReleaseManifest,
} from '@backstage/release-manifests';
import { migrateMovedPackages } from './migrate';
import { runYarnInstall } from '../../lib/utils';
import { run } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
const DEP_TYPES = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
];
const DEFAULT_PATTERN_GLOB = '@backstage/*';
type PkgVersionInfo = {
range: string;
target: string;
name: string;
location: string;
};
function extendsDefaultPattern(pattern: string): boolean {
if (!pattern.endsWith('/*')) {
return false;
}
return minimatch('@backstage/', pattern.slice(0, -1));
}
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { pattern: patternFlag, release, skipInstall, skipMigrate },
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
release: {
type: String,
description: 'Bump to a specific Backstage release line or version',
default: 'main',
},
skipInstall: {
type: Boolean,
description: 'Skips yarn install step',
},
skipMigrate: {
type: Boolean,
description: 'Skips migration of any moved packages',
},
},
},
undefined,
args,
);
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const yarnPluginEnabled = await hasBackstageYarnPlugin();
let pattern = patternFlag;
if (!pattern) {
console.log(`Using default pattern glob ${DEFAULT_PATTERN_GLOB}`);
pattern = DEFAULT_PATTERN_GLOB;
} else if (pattern === '*') {
throw new Error(`Rejected pattern '*', please use a more specific pattern`);
} else {
console.log(`Using custom pattern glob ${pattern}`);
}
let findTargetVersion: (name: string) => Promise<string>;
let releaseManifest: ReleaseManifest;
if (env.BACKSTAGE_MANIFEST_FILE) {
// Use specific manifest file if provided
releaseManifest = await fs.readJson(env.BACKSTAGE_MANIFEST_FILE);
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else if (semver.valid(release)) {
// Specific release specified. Be strict when resolving versions
releaseManifest = await getManifestByVersion({ version: release! });
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else {
// Release line specified. Be lenient when resolving versions.
if (release === 'next') {
const next = await getManifestByReleaseLine({
releaseLine: 'next',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
const main = await getManifestByReleaseLine({
releaseLine: 'main',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
// Prefer manifest with the latest release version
releaseManifest = semver.gt(next.releaseVersion, main.releaseVersion)
? next
: main;
} else {
releaseManifest = await getManifestByReleaseLine({
releaseLine: release!,
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
}
findTargetVersion = createVersionFinder({
releaseLine: release,
releaseManifest,
});
}
if (yarnPluginEnabled) {
console.log();
console.log(
`Updating yarn plugin to v${releaseManifest.releaseVersion}...`,
);
console.log();
const yarnPluginUrl = env.BACKSTAGE_VERSIONS_BASE_URL
? `${env.BACKSTAGE_VERSIONS_BASE_URL}/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`
: `https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`;
await run(['yarn', 'plugin', 'import', yarnPluginUrl]).waitForExit();
console.log();
}
// First we discover all Backstage dependencies within our own repo
const dependencyMap = await mapDependencies(targetPaths.dir, pattern);
// Next check with the package registry to see which dependency ranges we need to bump
const versionBumps = new Map<string, PkgVersionInfo[]>();
await runConcurrentTasks({
concurrencyFactor: 4,
items: dependencyMap.entries(),
async worker([name, pkgs]) {
let target: string;
try {
target = await findTargetVersion(name);
} catch (error) {
if (isError(error) && error.name === 'NotFoundError') {
console.log(`Package info not found, ignoring package ${name}`);
return;
}
throw error;
}
for (const pkg of pkgs) {
versionBumps.set(
pkg.name,
(versionBumps.get(pkg.name) ?? []).concat({
name,
location: pkg.location,
range: `^${target}`, // TODO(Rugvip): Option to use something else than ^?
target,
}),
);
}
},
});
// Write all discovered version bumps to package.json in this repo
if (versionBumps.size === 0) {
console.log(chalk.green('All Backstage packages are up to date!'));
} else {
console.log(chalk.yellow('Some packages are outdated, updating'));
console.log();
const breakingUpdates = new Map<string, { from: string; to: string }>();
await runConcurrentTasks({
concurrencyFactor: 4,
items: versionBumps.entries(),
async worker([name, deps]) {
const pkgPath = resolvePath(deps[0].location, 'package.json');
const pkgJson = await fs.readJson(pkgPath);
for (const dep of deps) {
console.log(
`${chalk.cyan('bumping')} ${dep.name} in ${chalk.cyan(
name,
)} to ${chalk.yellow(dep.range)}`,
);
for (const depType of DEP_TYPES) {
if (depType in pkgJson && dep.name in pkgJson[depType]) {
const oldRange = pkgJson[depType][dep.name];
// backstage:^ are written to the lockfile as
// backstage:<backstage-version>, so that updates to
// backstage.json can be detected during yarn install. In order to
// locate the corresponding lockfile entry for "backstage:^"
// versions, we need to perform the same transformation.
const oldLockfileRange = await asLockfileVersion(oldRange);
const useBackstageRange =
yarnPluginEnabled &&
// Only use backstage:^ versions if the package is present in
// the manifest for the release we're bumping to.
releaseManifest.packages.find(
({ name: manifestPackageName }) =>
dep.name === manifestPackageName,
) &&
// Don't use backstage:^ versions for peerDependencies; they only
// support npm and workspace: versions.
depType !== 'peerDependencies';
const newRange = useBackstageRange ? 'backstage:^' : dep.range;
pkgJson[depType][dep.name] = newRange;
// Check if the update was at least a pre-v1 minor or post-v1 major release
const lockfileEntry = lockfile
.get(dep.name)
?.find(entry => entry.range === oldLockfileRange);
if (lockfileEntry) {
const from = lockfileEntry.version;
const to = dep.target;
if (!semver.satisfies(to, `^${from}`)) {
breakingUpdates.set(dep.name, { from, to });
}
}
}
}
}
await fs.writeJson(pkgPath, pkgJson, { spaces: 2 });
},
});
console.log();
// Do not update backstage.json when default pattern is not covered
if (extendsDefaultPattern(pattern)) {
await bumpBackstageJsonVersion(
releaseManifest.releaseVersion,
yarnPluginEnabled,
);
} else {
console.log(
chalk.yellow(
`Skipping backstage.json update as custom pattern is used`,
),
);
}
if (!skipInstall) {
await runYarnInstall();
} else {
console.log();
console.log(chalk.yellow(`Skipping yarn install`));
}
if (!skipMigrate) {
console.log();
const changed = await migrateMovedPackages({
pattern: patternFlag,
});
if (changed && !skipInstall) {
await runYarnInstall();
}
}
if (breakingUpdates.size > 0) {
console.log();
console.log(
chalk.yellow('⚠️ The following packages may have breaking changes:'),
);
console.log();
for (const [name, { from, to }] of Array.from(
breakingUpdates.entries(),
).sort()) {
console.log(
` ${chalk.yellow(name)} : ${chalk.yellow(from)} ~> ${chalk.yellow(
to,
)}`,
);
let path;
if (name.startsWith('@backstage/plugin-')) {
path = `plugins/${name.replace('@backstage/plugin-', '')}`;
} else if (name.startsWith('@backstage/')) {
path = `packages/${name.replace('@backstage/', '')}`;
}
if (path) {
// TODO(Rugvip): Grab these URLs and paths from package.json, possibly verify existence
// Possibly invent new "changelog" field in package.json or some sh*t.
console.log(
` https://github.com/backstage/backstage/blob/master/${path}/CHANGELOG.md`,
);
}
console.log();
}
} else {
console.log();
}
if (yarnPluginEnabled) {
console.log();
console.log(
chalk.blue(
`${chalk.bold(
'NOTE',
)}: this bump used backstage:^ versions in package.json files, since the Backstage ` +
`yarn plugin was detected in the repository. To migrate back to explicit npm versions, ` +
`remove the plugin by running "yarn plugin remove @yarnpkg/plugin-backstage", then ` +
`repeat this command.`,
),
);
console.log();
}
console.log(chalk.green('Version bump complete!'));
}
console.log();
};
export function createStrictVersionFinder(options: {
releaseManifest: ReleaseManifest;
}) {
const releasePackages = new Map(
options.releaseManifest.packages.map(p => [p.name, p.version]),
);
return async function findTargetVersion(name: string) {
console.log(`Checking for updates of ${name}`);
const manifestVersion = releasePackages.get(name);
if (manifestVersion) {
return manifestVersion;
}
throw new NotFoundError(`Package ${name} not found in release manifest`);
};
}
export function createVersionFinder(options: {
releaseLine?: string;
packageInfoFetcher?: () => Promise<YarnInfoInspectData>;
releaseManifest?: ReleaseManifest;
}) {
const {
releaseLine = 'latest',
packageInfoFetcher = fetchPackageInfo,
releaseManifest,
} = options;
// The main release line is just an alias for latest
const distTag = releaseLine === 'main' ? 'latest' : releaseLine;
const found = new Map<string, string>();
const releasePackages = new Map(
releaseManifest?.packages.map(p => [p.name, p.version]),
);
return async function findTargetVersion(name: string) {
const existing = found.get(name);
if (existing) {
return existing;
}
console.log(`Checking for updates of ${name}`);
const manifestVersion = releasePackages.get(name);
if (manifestVersion) {
return manifestVersion;
}
const info = await packageInfoFetcher(name);
const latestVersion = info['dist-tags'].latest;
if (!latestVersion) {
throw new Error(`No target 'latest' version found for ${name}`);
}
const taggedVersion = info['dist-tags'][distTag];
if (distTag === 'latest' || !taggedVersion) {
found.set(name, latestVersion);
return latestVersion;
}
const latestVersionDateStr = info.time[latestVersion];
const taggedVersionDateStr = info.time[taggedVersion];
if (!latestVersionDateStr) {
throw new Error(
`No time available for version '${latestVersion}' of ${name}`,
);
}
if (!taggedVersionDateStr) {
throw new Error(
`No time available for version '${taggedVersion}' of ${name}`,
);
}
const latestVersionRelease = new Date(latestVersionDateStr).getTime();
const taggedVersionRelease = new Date(taggedVersionDateStr).getTime();
if (latestVersionRelease > taggedVersionRelease) {
// Prefer latest version if it's newer.
found.set(name, latestVersion);
return latestVersion;
}
found.set(name, taggedVersion);
return taggedVersion;
};
}
function getBackstageJsonPath() {
return targetPaths.resolveRoot(BACKSTAGE_JSON);
}
async function getBackstageJson() {
const backstageJsonPath = getBackstageJsonPath();
return fs.readJSON(backstageJsonPath).catch(e => {
if (e.code === 'ENOENT') {
// gracefully continue in case the file doesn't exist
return;
}
throw e;
});
}
export async function bumpBackstageJsonVersion(
version: string,
useYarnPlugin?: boolean,
) {
const backstageJson = await getBackstageJson();
const prevVersion = backstageJson?.version;
if (prevVersion === version) {
return;
}
const { yellow, cyan, green } = chalk;
if (prevVersion) {
const from = encodeURIComponent(prevVersion);
const to = encodeURIComponent(version);
let link = `https://backstage.github.io/upgrade-helper/?from=${from}&to=${to}`;
if (useYarnPlugin) {
link += '&yarnPlugin=1';
}
console.log(
yellow(
`Upgraded from release ${green(prevVersion)} to ${green(
version,
)}, please review these template changes:`,
),
);
console.log();
console.log(` ${cyan(link)}`);
console.log();
} else {
console.log(
yellow(
`Your project is now at version ${version}, which has been written to ${BACKSTAGE_JSON}`,
),
);
}
await fs.writeJson(
getBackstageJsonPath(),
{ ...backstageJson, version },
{
spaces: 2,
encoding: 'utf8',
},
);
}
async function asLockfileVersion(version: string) {
if (version === 'backstage:^') {
return `backstage:${(await getBackstageJson())?.version}`;
}
return version;
}
@@ -0,0 +1,341 @@
/*
* 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 {
MockDirectory,
createMockDirectory,
} from '@backstage/backend-test-utils';
import * as runObj from '@backstage/cli-common';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import migrate from './migrate';
import { withLogCollector } from '@backstage/test-utils';
import fs from 'fs-extra';
// Remove log coloring to simplify log matching
jest.mock('chalk', () => ({
red: (str: string) => str,
blue: (str: string) => str,
cyan: (str: string) => str,
green: (str: string) => str,
magenta: (str: string) => str,
yellow: (str: string) => str,
}));
let mockDir: MockDirectory;
jest.mock('@backstage/cli-common', () => {
const actual = jest.requireActual('@backstage/cli-common');
return {
...actual,
findPaths: () => ({
resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args),
get targetDir() {
return mockDir.path;
},
}),
run: jest.fn().mockReturnValue({
exitCode: null,
waitForExit: jest.fn().mockResolvedValue(undefined),
}),
};
});
function expectLogsToMatch(receivedLogs: String[], expected: String[]): void {
expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort());
}
describe('versions:migrate', () => {
mockDir = createMockDirectory();
beforeAll(() => overrideTargetPaths(mockDir.path));
beforeEach(() => {
(runObj.run as jest.Mock).mockReturnValue({
exitCode: null,
waitForExit: jest.fn().mockResolvedValue(undefined),
});
});
afterEach(() => {
(runObj.run as jest.Mock).mockClear();
});
it('should bump to the moved version when the package is moved', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {
custom: {
'package.json': JSON.stringify({
name: '@backstage-extra/custom',
version: '1.0.1',
backstage: {
moved: '@backstage-community/custom',
},
}),
},
'custom-two': {
'package.json': JSON.stringify({
name: '@backstage-extra/custom-two',
version: '1.0.0',
backstage: {
moved: '@backstage-community/custom-two',
},
}),
},
},
},
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '^1.0.5',
'@backstage/custom': '^1.0.1',
'@backstage/custom-two': '^1.0.0',
},
}),
},
b: {
'package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '^1.0.3',
'@backstage/theme': '^1.0.0',
'@backstage/custom': '^1.1.0',
'@backstage/custom-two': '^1.0.0',
},
}),
},
},
});
const { warn, log: logs } = await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expectLogsToMatch(logs, [
'Checking for moved packages to the @backstage-community namespace...',
'Found a moved package @backstage/custom@^1.0.1 -> @backstage-community/custom in a (dependencies)',
'Found a moved package @backstage/custom-two@^1.0.0 -> @backstage-community/custom-two in a (dependencies)',
'Found a moved package @backstage/custom@^1.1.0 -> @backstage-community/custom in b (dependencies)',
'Found a moved package @backstage/custom-two@^1.0.0 -> @backstage-community/custom-two in b (dependencies)',
]);
expectLogsToMatch(warn, [
'Could not find package.json for @backstage/core@^1.0.5 in a (dependencies)',
'Could not find package.json for @backstage/core@^1.0.3 in b (dependencies)',
'Could not find package.json for @backstage/theme@^1.0.0 in b (dependencies)',
]);
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
['yarn', 'install'],
expect.any(Object),
);
const packageA = await fs.readJson(
mockDir.resolve('packages/a/package.json'),
);
expect(packageA).toEqual({
name: 'a',
dependencies: {
'@backstage-community/custom': '^1.0.1',
'@backstage-community/custom-two': '^1.0.0',
'@backstage/core': '^1.0.5',
},
});
const packageB = await fs.readJson(
mockDir.resolve('packages/b/package.json'),
);
expect(packageB).toEqual({
name: 'b',
dependencies: {
'@backstage-community/custom': '^1.1.0',
'@backstage-community/custom-two': '^1.0.0',
'@backstage/core': '^1.0.3',
'@backstage/theme': '^1.0.0',
},
});
});
it('should replace the occurrences of the moved package in files inside the correct package', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {
custom: {
'package.json': JSON.stringify({
name: '@backstage-extra/custom',
version: '1.0.1',
backstage: {
moved: '@backstage-community/custom',
},
}),
},
'custom-two': {
'package.json': JSON.stringify({
name: '@backstage-extra/custom-two',
version: '1.0.0',
backstage: {
moved: '@backstage-community/custom-two',
},
}),
},
},
},
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '^1.0.5',
'@backstage/custom': '^1.0.1',
'@backstage/custom-two': '^1.0.0',
},
}),
src: {
'index.ts': "import { myThing } from '@backstage/custom';",
'index.test.ts': "import { myThing } from '@backstage/custom-two';",
},
},
b: {
'package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '^1.0.3',
'@backstage/theme': '^1.0.0',
'@backstage/custom': '^1.1.0',
'@backstage/custom-two': '^1.0.0',
},
}),
},
},
});
await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
['yarn', 'install'],
expect.any(Object),
);
const indexA = await fs.readFile(
mockDir.resolve('packages/a/src/index.ts'),
'utf-8',
);
expect(indexA).toEqual(
"import { myThing } from '@backstage-community/custom';",
);
const indexTestA = await fs.readFile(
mockDir.resolve('packages/a/src/index.test.ts'),
'utf-8',
);
expect(indexTestA).toEqual(
"import { myThing } from '@backstage-community/custom-two';",
);
});
it('should replace occurrences of changed packages, and is careful', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {
custom: {
'package.json': JSON.stringify({
name: '@backstage-extra/custom',
version: '1.0.1',
backstage: {
moved: '@backstage-community/custom',
},
}),
},
'custom-two': {
'package.json': JSON.stringify({
name: '@backstage-extra/custom-two',
version: '1.0.0',
}),
},
},
},
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '^1.0.5',
'@backstage/custom': '^1.0.1',
'@backstage/custom-two': '^1.0.0',
},
}),
src: {
'index.ts': "import { myThing } from '@backstage/custom';",
'index.test.ts': "import { myThing } from '@backstage/custom-two';",
},
},
b: {
'package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '^1.0.3',
'@backstage/theme': '^1.0.0',
'@backstage/custom': '^1.1.0',
'@backstage/custom-two': '^1.0.0',
},
}),
},
},
});
await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
['yarn', 'install'],
expect.any(Object),
);
const indexA = await fs.readFile(
mockDir.resolve('packages/a/src/index.ts'),
'utf-8',
);
expect(indexA).toEqual(
"import { myThing } from '@backstage-community/custom';",
);
const indexTestA = await fs.readFile(
mockDir.resolve('packages/a/src/index.test.ts'),
'utf-8',
);
expect(indexTestA).toEqual(
"import { myThing } from '@backstage/custom-two';",
);
});
});
@@ -0,0 +1,172 @@
/*
* 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 { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
import chalk from 'chalk';
import { resolve as resolvePath, join as joinPath } from 'node:path';
import { cli } from 'cleye';
import { readJson, writeJson } from 'fs-extra';
import { minimatch } from 'minimatch';
import { runYarnInstall } from '../../lib/utils';
import replace from 'replace-in-file';
import type { CliCommandContext } from '@backstage/cli-node';
declare module 'replace-in-file' {
export default function (config: {
files: string | string[];
processor: (content: string, file: string) => string;
ignore?: string | string[];
allowEmptyPaths?: boolean;
}): Promise<
{
file: string;
hasChanged: boolean;
numMatches?: number;
numReplacements?: number;
}[]
>;
}
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { pattern, skipCodeChanges },
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
skipCodeChanges: {
type: Boolean,
description: 'Skip code changes and only update package.json files',
},
},
},
undefined,
args,
);
const changed = await migrateMovedPackages({
pattern,
skipCodeChanges,
});
if (changed) {
await runYarnInstall();
}
};
export async function migrateMovedPackages(options?: {
pattern?: string;
skipCodeChanges?: boolean;
}) {
console.log(
'Checking for moved packages to the @backstage-community namespace...',
);
const packages = await PackageGraph.listTargetPackages();
let didAnythingChange = false;
for (const pkg of packages) {
const pkgName = pkg.packageJson.name;
let didPackageChange = false;
const movedPackages = new Map<string, string>();
for (const depType of [
'dependencies',
'devDependencies',
'peerDependencies',
] as const) {
const depsObj = pkg.packageJson[depType];
if (!depsObj) {
continue;
}
for (const [depName, depVersion] of Object.entries(depsObj)) {
if (options?.pattern && !minimatch(depName, options.pattern)) {
continue;
}
let packageInfo: BackstagePackageJson;
try {
packageInfo = await readJson(
require.resolve(`${depName}/package.json`, {
paths: [pkg.dir],
}),
);
} catch (ex) {
console.warn(
chalk.yellow(
`Could not find package.json for ${depName}@${depVersion} in ${pkgName} (${depType})`,
),
);
continue;
}
const movedPackageName = packageInfo.backstage?.moved;
if (movedPackageName) {
movedPackages.set(depName, movedPackageName);
console.log(
chalk.yellow(
`Found a moved package ${depName}@${depVersion} -> ${movedPackageName} in ${pkgName} (${depType})`,
),
);
didPackageChange = true;
didAnythingChange = true;
depsObj[movedPackageName] = depsObj[depName];
delete depsObj[depName];
}
}
}
if (didPackageChange) {
await writeJson(resolvePath(pkg.dir, 'package.json'), pkg.packageJson, {
spaces: 2,
});
if (!options?.skipCodeChanges) {
// Replace all occurrences of the old package names in the code.
const files = await replace({
files: joinPath(pkg.dir, 'src', '**'),
allowEmptyPaths: true,
processor: content => {
return Array.from(movedPackages.entries()).reduce(
(newContent, [oldName, newName]) => {
return newContent
.replace(new RegExp(`"${oldName}"`, 'g'), `"${newName}"`)
.replace(new RegExp(`'${oldName}'`, 'g'), `'${newName}'`)
.replace(new RegExp(`${oldName}/`, 'g'), `${newName}/`);
},
content,
);
},
});
if (files.length > 0) {
console.log(
chalk.green(
`Updated ${files.length} files in ${pkgName} to use the new package names`,
),
);
}
}
}
}
return didAnythingChange;
}
+77
View File
@@ -0,0 +1,77 @@
/*
* 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 { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['versions:migrate'],
description:
'Migrate any plugins that have been moved to the @backstage-community namespace automatically',
execute: { loader: () => import('./commands/versions/migrate') },
});
reg.addCommand({
path: ['versions:bump'],
description: 'Bump Backstage packages to the latest versions',
execute: { loader: () => import('./commands/versions/bump') },
});
reg.addCommand({
path: ['migrate', 'package-roles'],
description: `Add package role field to packages that don't have it`,
execute: {
loader: () => import('./commands/packageRole'),
},
});
reg.addCommand({
path: ['migrate', 'package-scripts'],
description: 'Set package scripts according to each package role',
execute: {
loader: () => import('./commands/packageScripts'),
},
});
reg.addCommand({
path: ['migrate', 'package-exports'],
description: 'Synchronize package subpath export definitions',
execute: {
loader: () => import('./commands/packageExports'),
},
});
reg.addCommand({
path: ['migrate', 'package-lint-configs'],
description:
'Migrates all packages to use @backstage/cli/config/eslint-factory',
execute: {
loader: () => import('./commands/packageLintConfigs'),
},
});
reg.addCommand({
path: ['migrate', 'react-router-deps'],
description:
'Migrates the react-router dependencies for all packages to be peer dependencies',
execute: {
loader: () => import('./commands/reactRouterDeps'),
},
});
},
});
@@ -0,0 +1,51 @@
/*
* 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 ora from 'ora';
import chalk from 'chalk';
import { run } from '@backstage/cli-common';
export async function runYarnInstall() {
const spinner = ora({
prefixText: `Running ${chalk.blue('yarn install')} to install new versions`,
spinner: 'arc',
color: 'green',
}).start();
const installOutput = new Array<Buffer>();
try {
await run(['yarn', 'install'], {
env: {
FORCE_COLOR: 'true',
// We filter out all of the npm_* environment variables that are added when
// executing through yarn. This works around an issue where these variables
// incorrectly override local yarn or npm config in the project directory.
...Object.fromEntries(
Object.entries(process.env).map(([name, value]) =>
name.startsWith('npm_') ? [name, undefined] : [name, value],
),
),
},
onStdout: data => installOutput.push(data),
onStderr: data => installOutput.push(data),
}).waitForExit();
spinner.succeed();
} catch (error) {
spinner.fail();
process.stdout.write(Buffer.concat(installOutput));
throw error;
}
}
@@ -0,0 +1,159 @@
/*
* 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 * as runObj from '@backstage/cli-common';
import * as yarn from './yarn';
import { fetchPackageInfo, mapDependencies } from './packages';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { NotFoundError } from '@backstage/errors';
jest.mock('@backstage/cli-common', () => {
const actual = jest.requireActual('@backstage/cli-common');
return {
...actual,
runOutput: jest.fn(),
};
});
jest.mock('./yarn', () => {
return {
detectYarnVersion: jest.fn(),
};
});
describe('fetchPackageInfo', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should forward info for yarn classic', async () => {
jest
.spyOn(runObj, 'runOutput')
.mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runOutput).toHaveBeenCalledWith([
'yarn',
'info',
'--json',
'my-package',
]);
});
it('should forward info for yarn berry', async () => {
jest.spyOn(runObj, 'runOutput').mockResolvedValue(`{"the":"data"}`);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runOutput).toHaveBeenCalledWith([
'yarn',
'npm',
'info',
'--json',
'my-package',
]);
});
it('should throw if no info with yarn classic', async () => {
jest.spyOn(runObj, 'runOutput').mockResolvedValue('');
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
it('should throw if no info with yarn berry', async () => {
const error = new Error('Command failed');
(error as Error & { stdout?: string }).stdout =
'bla bla bla Response Code: 404 bla bla';
jest.spyOn(runObj, 'runOutput').mockRejectedValue(error);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
});
describe('mapDependencies', () => {
const mockDir = createMockDirectory();
afterEach(() => {
jest.resetAllMocks();
});
it('should read dependencies', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: ['pkgs/*'],
}),
pkgs: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '1 || 2',
},
}),
},
b: {
'package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '3',
'@backstage/cli': '^0',
},
}),
},
},
});
const dependencyMap = await mapDependencies(mockDir.path, '@backstage/*');
expect(Array.from(dependencyMap)).toEqual([
[
'@backstage/core',
[
{
name: 'a',
range: '1 || 2',
location: mockDir.resolve('pkgs/a'),
},
{
name: 'b',
range: '3',
location: mockDir.resolve('pkgs/b'),
},
],
],
[
'@backstage/cli',
[
{
name: 'b',
range: '^0',
location: mockDir.resolve('pkgs/b'),
},
],
],
]);
});
});
@@ -0,0 +1,126 @@
/*
* 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 { minimatch } from 'minimatch';
import { getPackages } from '@manypkg/get-packages';
import { detectYarnVersion } from './yarn';
import { runOutput } from '@backstage/cli-common';
import { NotFoundError } from '@backstage/errors';
const DEP_TYPES = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
] as const;
// Package data as returned by `yarn info`
export type YarnInfoInspectData = {
name: string;
'dist-tags': Record<string, string>;
versions: string[];
time: { [version: string]: string };
};
// Possible `yarn info` output
type YarnInfo = {
type: 'inspect';
data: YarnInfoInspectData | { type: string; data: unknown };
};
type PkgVersionInfo = {
range: string;
name: string;
location: string;
};
export async function fetchPackageInfo(
name: string,
): Promise<YarnInfoInspectData> {
const yarnVersion = await detectYarnVersion();
const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info'];
try {
const output = await runOutput(['yarn', ...cmd, '--json', name]);
if (!output) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
if (yarnVersion === 'berry') {
return JSON.parse(output) as YarnInfoInspectData;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
} catch (error) {
if (yarnVersion === 'classic') {
throw error;
}
if (
error instanceof Error &&
'stdout' in error &&
typeof error.stdout === 'string' &&
error.stdout.includes('Response Code: 404')
) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
throw error;
}
}
/** Map all dependencies in the repo as dependency => dependents */
export async function mapDependencies(
targetDir: string,
pattern: string,
): Promise<Map<string, PkgVersionInfo[]>> {
const { packages, root } = await getPackages(targetDir);
// Include root package.json too
packages.push(root);
const dependencyMap = new Map<string, PkgVersionInfo[]>();
for (const pkg of packages) {
const deps = DEP_TYPES.flatMap(
t => Object.entries(pkg.packageJson[t] ?? {}) as [string, string][],
);
for (const [name, range] of deps) {
if (minimatch(name, pattern)) {
dependencyMap.set(
name,
(dependencyMap.get(name) ?? []).concat({
range,
name: pkg.packageJson.name,
location: pkg.dir,
}),
);
}
}
}
return dependencyMap;
}
@@ -0,0 +1,42 @@
/*
* Copyright 2022 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 { assertError, ForwardedError } from '@backstage/errors';
import { runOutput } from '@backstage/cli-common';
const versions = new Map<string, Promise<'classic' | 'berry'>>();
export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> {
const cwd = dir ?? process.cwd();
if (versions.has(cwd)) {
return versions.get(cwd)!;
}
const promise = Promise.resolve().then(async () => {
try {
const stdout = await runOutput(['yarn', '--version'], {
cwd,
});
return stdout.trim().startsWith('1.') ? 'classic' : 'berry';
} catch (error) {
assertError(error);
throw new ForwardedError('Failed to determine yarn version', error);
}
});
versions.set(cwd, promise);
return promise;
}