diff --git a/.changeset/rotten-queens-grow.md b/.changeset/rotten-queens-grow.md new file mode 100644 index 0000000000..b636414036 --- /dev/null +++ b/.changeset/rotten-queens-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Use `PackageRole` type explicitly diff --git a/.changeset/tender-zoos-rush.md b/.changeset/tender-zoos-rush.md new file mode 100644 index 0000000000..0e09a75e44 --- /dev/null +++ b/.changeset/tender-zoos-rush.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli-node': patch +'@backstage/cli': patch +--- + +Added `versions:migrate` command to help move packages to the new `@backstage-community` namespace diff --git a/packages/backend-dynamic-feature-service/api-report.md b/packages/backend-dynamic-feature-service/api-report.md index 8075acabfb..57b2228975 100644 --- a/packages/backend-dynamic-feature-service/api-report.md +++ b/packages/backend-dynamic-feature-service/api-report.md @@ -240,7 +240,9 @@ export interface NewBackendPluginInstaller { export type ScannedPluginManifest = BackstagePackageJson & Required> & Required> & { - backstage: Required; + backstage: { + role: PackageRole; + }; }; // @public (undocumented) diff --git a/packages/backend-dynamic-feature-service/src/scanner/types.ts b/packages/backend-dynamic-feature-service/src/scanner/types.ts index b456ffeea1..5bac92f064 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/types.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BackstagePackageJson } from '@backstage/cli-node'; +import { BackstagePackageJson, PackageRole } from '@backstage/cli-node'; /** * @public @@ -30,5 +30,7 @@ export interface ScannedPluginPackage { export type ScannedPluginManifest = BackstagePackageJson & Required> & Required> & { - backstage: Required; + backstage: { + role: PackageRole; + }; }; diff --git a/packages/cli-node/api-report.md b/packages/cli-node/api-report.md index 4bd152dca8..5b9b8485b8 100644 --- a/packages/cli-node/api-report.md +++ b/packages/cli-node/api-report.md @@ -17,6 +17,7 @@ export interface BackstagePackageJson { // (undocumented) backstage?: { role?: PackageRole; + moved?: string; }; // (undocumented) bundled?: boolean; diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index cd01ec833f..68ba614c94 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -45,6 +45,7 @@ export interface BackstagePackageJson { backstage?: { role?: PackageRole; + moved?: string; }; exports?: JsonValue; @@ -137,6 +138,7 @@ export class PackageGraph extends Map { */ static async listTargetPackages(): Promise { const { packages } = await getPackages(paths.targetDir); + return packages as BackstagePackage[]; } diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 9a471e2ca6..a913b63f60 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -23,6 +23,7 @@ Commands: migrate [command] versions:bump [options] versions:check [options] + versions:migrate [options] clean build-workspace [options] [packages...] create-github-app @@ -600,6 +601,7 @@ Options: --pattern --release --skip-install + --skip-migrate -h, --help ``` @@ -612,3 +614,13 @@ Options: --fix -h, --help ``` + +### `backstage-cli versions:migrate` + +``` +Usage: backstage-cli versions:migrate [options] + +Options: + --pattern + -h, --help +``` diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 431bd6cc68..609c7b5946 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -386,6 +386,7 @@ export function registerCommands(program: Command) { 'main', ) .option('--skip-install', 'Skips yarn install step') + .option('--skip-migrate', 'Skips migration of any moved packages') .description('Bump Backstage packages to the latest versions') .action(lazy(() => import('./versions/bump').then(m => m.default))); @@ -395,6 +396,17 @@ export function registerCommands(program: Command) { .description('Check Backstage package versioning') .action(lazy(() => import('./versions/lint').then(m => m.default))); + program + .command('versions:migrate') + .option( + '--pattern ', + 'Override glob for matching packages to upgrade', + ) + .description( + 'Migrate any plugins that have been moved to the @backstage-community namespace automatically', + ) + .action(lazy(() => import('./versions/migrate').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of package variant program .command('clean') diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index d00b4ce8da..a6537423c8 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -58,16 +58,16 @@ jest.mock('ora', () => ({ })); let mockDir: MockDirectory; - -jest.mock('../../lib/paths', () => ({ - paths: { +jest.mock('@backstage/cli-common', () => ({ + ...jest.requireActual('@backstage/cli-common'), + findPaths: () => ({ resolveTargetRoot(filename: string) { return mockDir.resolve(filename); }, get targetDir() { return mockDir.path; }, - }, + }), })); jest.mock('../../lib/run', () => { @@ -200,7 +200,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); expectLogsToMatch(logs, [ @@ -303,7 +303,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'main', @@ -419,7 +419,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); expectLogsToMatch(logs, [ @@ -517,7 +517,7 @@ describe('bump', () => { (_, res, ctx) => res(ctx.status(404), ctx.json({})), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await expect( bump({ pattern: null, release: '999.0.1' } as unknown as Command), ).rejects.toThrow('No release found for 999.0.1 version'); @@ -622,7 +622,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'next' } as unknown as Command); }); expectLogsToMatch(logs, [ @@ -718,7 +718,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: '@{backstage,backstage-extra}/*', release: 'main', @@ -837,7 +837,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); expectLogsToMatch(logs, [ @@ -952,7 +952,7 @@ describe('bump', () => { ), ), ); - const { log: logs } = await withLogCollector(['log'], async () => { + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); expectLogsToMatch(logs, [ diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index d64d5a9756..dc0aa27530 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -40,6 +40,7 @@ import { } from '@backstage/release-manifests'; import 'global-agent/bootstrap'; import { PackageGraph } from '@backstage/cli-node'; +import { migrateMovedPackages } from './migrate'; const DEP_TYPES = [ 'dependencies', @@ -264,6 +265,15 @@ export default async (opts: OptionValues) => { console.log(chalk.yellow(`Skipping yarn install`)); } + if (!opts.skipMigrate) { + const changed = await migrateMovedPackages({ + pattern: opts.pattern, + }); + if (changed && !opts.skipInstall) { + await runYarnInstall(); + } + } + if (breakingUpdates.size > 0) { console.log(); console.log( @@ -470,7 +480,7 @@ export async function bumpBackstageJsonVersion(version: string) { ); } -async function runYarnInstall() { +export async function runYarnInstall() { const spinner = ora({ prefixText: `Running ${chalk.blue('yarn install')} to install new versions`, spinner: 'arc', diff --git a/packages/cli/src/commands/versions/migrate.test.ts b/packages/cli/src/commands/versions/migrate.test.ts new file mode 100644 index 0000000000..46c7f9bfb8 --- /dev/null +++ b/packages/cli/src/commands/versions/migrate.test.ts @@ -0,0 +1,167 @@ +/* + * 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 run from '../../lib/run'; +import migrate from './migrate'; +import { withLogCollector } from '@backstage/test-utils'; +import fs from 'fs-extra'; +import { expectLogsToMatch } from '../../lib/new/factories/common/testUtils'; + +// 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', () => ({ + ...jest.requireActual('@backstage/cli-common'), + findPaths: () => ({ + resolveTargetRoot(filename: string) { + return mockDir.resolve(filename); + }, + get targetDir() { + return mockDir.path; + }, + }), +})); + +jest.mock('../../lib/run', () => { + return { + run: jest.fn(), + }; +}); + +describe('versions:migrate', () => { + mockDir = createMockDirectory(); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should bump to the moved version when the package is moved', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + workspaces: { + packages: ['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', + }, + }), + }, + }, + }); + + jest.spyOn(run, 'run').mockResolvedValue(undefined); + + const { warn, log: logs } = await withLogCollector(async () => { + await migrate({}); + }); + + expectLogsToMatch(logs, [ + '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(run.run).toHaveBeenCalledTimes(1); + expect(run.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', + }, + }); + }); +}); diff --git a/packages/cli/src/commands/versions/migrate.ts b/packages/cli/src/commands/versions/migrate.ts new file mode 100644 index 0000000000..f824fa2b96 --- /dev/null +++ b/packages/cli/src/commands/versions/migrate.ts @@ -0,0 +1,113 @@ +/* + * 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 } from 'path'; +import { OptionValues } from 'commander'; +import { readJson, writeJson } from 'fs-extra'; +import { minimatch } from 'minimatch'; +import { runYarnInstall } from './bump'; + +export default async (options: OptionValues) => { + const changed = await migrateMovedPackages({ + pattern: options.pattern, + }); + + if (changed) { + await runYarnInstall(); + } +}; + +export async function migrateMovedPackages(options?: { pattern?: string }) { + const packages = await PackageGraph.listTargetPackages(); + + const thingsThatHaveMoved = new Map< + string, + { + dependencies: { [k: string]: string }; + devDependencies: { [k: string]: string }; + peerDependencies: { [k: string]: string }; + } + >(); + + let didAnythingChange = false; + + for (const pkg of packages) { + const pkgName = pkg.packageJson.name; + thingsThatHaveMoved.set(pkgName, { + dependencies: {}, + devDependencies: {}, + peerDependencies: {}, + }); + + let didPackageChange = false; + + 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) { + 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, + }); + } + } + + return didAnythingChange; +}