From a7e82c9b017424f4adeab90fd3962b11fc788979 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 8 Sep 2022 13:01:46 +0200 Subject: [PATCH] cli: update versions:bump to work with Yarn 3 Signed-off-by: Patrik Oldsberg --- .changeset/weak-radios-sin.md | 5 + .../cli/src/commands/versions/bump.test.ts | 125 ++++-------------- packages/cli/src/lib/run.ts | 2 +- .../cli/src/lib/versioning/packages.test.ts | 60 +++++++-- packages/cli/src/lib/versioning/packages.ts | 51 +++++-- packages/cli/src/lib/yarn.ts | 49 +++++++ 6 files changed, 173 insertions(+), 119 deletions(-) create mode 100644 .changeset/weak-radios-sin.md create mode 100644 packages/cli/src/lib/yarn.ts diff --git a/.changeset/weak-radios-sin.md b/.changeset/weak-radios-sin.md new file mode 100644 index 0000000000..e2aac006d4 --- /dev/null +++ b/.changeset/weak-radios-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated `versions:bump` command to be compatible with Yarn 3. diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index a127d15953..25682a4cd4 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -28,6 +28,7 @@ import { import { YarnInfoInspectData } from '../../lib/versioning/packages'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; +import { NotFoundError } from '@backstage/errors'; // Remove log coloring to simplify log matching jest.mock('chalk', () => ({ @@ -54,7 +55,15 @@ jest.mock('ora', () => ({ jest.mock('../../lib/run', () => { return { run: jest.fn(), - runPlain: jest.fn(), + }; +}); + +const mockFetchPackageInfo = jest.fn(); +jest.mock('../../lib/versioning/packages', () => { + const actual = jest.requireActual('../../lib/versioning/packages'); + return { + ...actual, + fetchPackageInfo: (name: string) => mockFetchPackageInfo(name), }; }); @@ -105,6 +114,14 @@ const lockfileMockResult = `${HEADER} `; describe('bump', () => { + beforeEach(() => { + mockFetchPackageInfo.mockImplementation(async name => ({ + name: name, + 'dist-tags': { + latest: REGISTRY_VERSIONS[name], + }, + })); + }); afterEach(() => { mockFs.restore(); jest.resetAllMocks(); @@ -138,17 +155,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -184,19 +190,10 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(3); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(3); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core-api'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -251,17 +248,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -309,19 +295,9 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(2); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).not.toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(2); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).not.toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -372,17 +348,6 @@ describe('bump', () => { }, }), }); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); @@ -480,17 +445,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -614,17 +568,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -672,19 +615,9 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(5); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(5); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -743,7 +676,7 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async () => ''); + mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope')); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 4efb791c30..1ef25cae62 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -25,7 +25,7 @@ import { promisify } from 'util'; import { LogFunc } from './logging'; import { assertError, ForwardedError } from '@backstage/errors'; -const execFile = promisify(execFileCb); +export const execFile = promisify(execFileCb); type SpawnOptionsPartialEnv = Omit & { env?: Partial; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index 975be74abd..5a255532f9 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -17,13 +17,20 @@ import mockFs from 'mock-fs'; import path from 'path'; import * as runObj from '../run'; +import * as yarn from '../yarn'; import { fetchPackageInfo, mapDependencies } from './packages'; import { NotFoundError } from '../errors'; jest.mock('../run', () => { return { run: jest.fn(), - runPlain: jest.fn(), + execFile: jest.fn(), + }; +}); + +jest.mock('../yarn', () => { + return { + detectYarnVersion: jest.fn(), }; }); @@ -32,24 +39,55 @@ describe('fetchPackageInfo', () => { jest.resetAllMocks(); }); - it('should forward info', async () => { - jest - .spyOn(runObj, 'runPlain') - .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`); + it('should forward info for yarn classic', async () => { + jest.spyOn(runObj, 'execFile').mockResolvedValue({ + stdout: `{"type":"inspect","data":{"the":"data"}}`, + stderr: '', + }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic'); await expect(fetchPackageInfo('my-package')).resolves.toEqual({ the: 'data', }); - expect(runObj.runPlain).toHaveBeenCalledWith( + expect(runObj.execFile).toHaveBeenCalledWith( 'yarn', - 'info', - '--json', - 'my-package', + ['info', '--json', 'my-package'], + { shell: true }, ); }); - it('should throw if no info', async () => { - jest.spyOn(runObj, 'runPlain').mockResolvedValue(''); + it('should forward info for yarn berry', async () => { + jest + .spyOn(runObj, 'execFile') + .mockResolvedValue({ stdout: `{"the":"data"}`, stderr: '' }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry'); + + await expect(fetchPackageInfo('my-package')).resolves.toEqual({ + the: 'data', + }); + expect(runObj.execFile).toHaveBeenCalledWith( + 'yarn', + ['npm', 'info', '--json', 'my-package'], + { shell: true }, + ); + }); + + it('should throw if no info with yarn classic', async () => { + jest + .spyOn(runObj, 'execFile') + .mockResolvedValue({ stdout: '', stderr: '' }); + 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 () => { + jest + .spyOn(runObj, 'execFile') + .mockRejectedValue({ stdout: 'bla bla bla Response Code: 404 bla bla' }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry'); await expect(fetchPackageInfo('my-package')).rejects.toThrow( new NotFoundError(`No package information found for package my-package`), diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index ed7266a692..70229b6fed 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -13,10 +13,12 @@ * 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 { runPlain } from '../../lib/run'; import { NotFoundError } from '../errors'; +import { detectYarnVersion } from '../yarn'; +import { execFile } from '../run'; const DEP_TYPES = [ 'dependencies', @@ -48,18 +50,45 @@ type PkgVersionInfo = { export async function fetchPackageInfo( name: string, ): Promise { - const output = await runPlain('yarn', 'info', '--json', name); + const yarnVersion = await detectYarnVersion(); - if (!output) { - throw new NotFoundError(`No package information found for package ${name}`); + const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info']; + try { + const { stdout: output } = await execFile( + 'yarn', + [...cmd, '--json', name], + { shell: true }, + ); + + 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?.stdout.includes('Response Code: 404')) { + throw new NotFoundError( + `No package information found for package ${name}`, + ); + } + + throw error; } - - 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; } /** Map all dependencies in the repo as dependency => dependents */ diff --git a/packages/cli/src/lib/yarn.ts b/packages/cli/src/lib/yarn.ts new file mode 100644 index 0000000000..b6c0383bc2 --- /dev/null +++ b/packages/cli/src/lib/yarn.ts @@ -0,0 +1,49 @@ +/* + * 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 { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; + +const execFile = promisify(execFileCb); + +const versions = new Map>(); + +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 execFile('yarn', ['--version'], { + shell: true, + cwd, + }); + return stdout.trim().startsWith('1.') ? 'classic' : 'berry'; + } catch (error) { + assertError(error); + if ('stderr' in error) { + process.stderr.write(error.stderr as Buffer); + } + throw new ForwardedError('Failed to determine yarn version', error); + } + }); + + versions.set(cwd, promise); + return promise; +}