From af665eadb89552a2b2d92da636a300f01c090e08 Mon Sep 17 00:00:00 2001 From: Tyler Davis Date: Sun, 20 Oct 2024 14:59:03 +1100 Subject: [PATCH] add minimal PackageManager and Lockfile interfaces Signed-off-by: Tyler Davis --- .changeset/mean-shirts-design.md | 5 + .gitignore | 1 + packages/cli-node/package.json | 3 +- packages/cli-node/src/errors.ts | 32 ++++ packages/cli-node/src/pacman/Lockfile.ts | 77 ++++++++++ .../src/pacman/PackageManager.test.ts | 113 ++++++++++++++ .../cli-node/src/pacman/PackageManager.ts | 138 ++++++++++++++++++ packages/cli-node/src/pacman/index.ts | 24 +++ .../cli-node/src/pacman/yarn/Yarn.test.ts | 58 ++++++++ packages/cli-node/src/pacman/yarn/Yarn.ts | 122 ++++++++++++++++ packages/cli-node/src/pacman/yarn/index.ts | 17 +++ packages/cli-node/src/pacman/yarn/types.ts | 19 +++ packages/cli-node/src/util.ts | 87 ++++++++++- yarn.lock | 1 + 14 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 .changeset/mean-shirts-design.md create mode 100644 packages/cli-node/src/errors.ts create mode 100644 packages/cli-node/src/pacman/Lockfile.ts create mode 100644 packages/cli-node/src/pacman/PackageManager.test.ts create mode 100644 packages/cli-node/src/pacman/PackageManager.ts create mode 100644 packages/cli-node/src/pacman/index.ts create mode 100644 packages/cli-node/src/pacman/yarn/Yarn.test.ts create mode 100644 packages/cli-node/src/pacman/yarn/Yarn.ts create mode 100644 packages/cli-node/src/pacman/yarn/index.ts create mode 100644 packages/cli-node/src/pacman/yarn/types.ts diff --git a/.changeset/mean-shirts-design.md b/.changeset/mean-shirts-design.md new file mode 100644 index 0000000000..ea89c6ecfd --- /dev/null +++ b/.changeset/mean-shirts-design.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +add PackageManager and Lockfile interfaces for future use diff --git a/.gitignore b/.gitignore index 865918c1f7..6ef45c07af 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ typings/ # Node version directives .nvmrc +.tool-versions # Optional eslint cache .eslintcache diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 11d6d568ac..7e2dcdab24 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^" } } diff --git a/packages/cli-node/src/errors.ts b/packages/cli-node/src/errors.ts new file mode 100644 index 0000000000..8be5faeb4f --- /dev/null +++ b/packages/cli-node/src/errors.ts @@ -0,0 +1,32 @@ +/* + * 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 { CustomErrorBase } from '@backstage/errors'; + +export class ExitCodeError extends CustomErrorBase { + readonly code: number; + + constructor(code: number, command?: string) { + super( + command + ? `Command '${command}' exited with code ${code}` + : `Child exited with code ${code}`, + ); + this.code = code; + } +} + +export class NotFoundError extends CustomErrorBase {} diff --git a/packages/cli-node/src/pacman/Lockfile.ts b/packages/cli-node/src/pacman/Lockfile.ts new file mode 100644 index 0000000000..51ba07827b --- /dev/null +++ b/packages/cli-node/src/pacman/Lockfile.ts @@ -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. + */ + +/** + * An entry for a single package in a {@link Lockfile}. + * + * @public + */ +export type LockfileEntry = { + range: string; + version: string; +}; + +/** + * An entry for a single difference between two {@link Lockfile}s. + * + * @public + */ +export type LockfileDiffEntry = { + name: string; + range: string; +}; + +/** + * Represents the difference between two {@link Lockfile}s. + * + * @public + */ +export type LockfileDiff = { + added: LockfileDiffEntry[]; + changed: LockfileDiffEntry[]; + removed: LockfileDiffEntry[]; +}; + +/** + * Represents the lockfile for a package manager. + * + * @public + */ +export interface Lockfile { + /** Get the entries for a single package in the lockfile */ + get(name: string): LockfileEntry[] | undefined; + + /** Returns the names of all packages available in the lockfile */ + keys(): IterableIterator; + + /** + * Creates a simplified dependency graph from the lockfile data, where each + * key is a package, and the value is a set of all packages that it depends on + * across all versions. + */ + createSimplifiedDependencyGraph(): Map>; + + /** + * Diff with another lockfile, returning entries that have been + * added, changed, and removed compared to the other lockfile. + */ + diff(otherLockfile: Lockfile): LockfileDiff; + + /** + * Generates a sha1 hex hash of the dependency graph for a package. + */ + getDependencyTreeHash(startName: string): string; +} diff --git a/packages/cli-node/src/pacman/PackageManager.test.ts b/packages/cli-node/src/pacman/PackageManager.test.ts new file mode 100644 index 0000000000..466c9ee7a9 --- /dev/null +++ b/packages/cli-node/src/pacman/PackageManager.test.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 { createMockDirectory } from '@backstage/backend-test-utils'; +import { detectPackageManager } from './PackageManager'; +import { Yarn } from './yarn'; +import { withLogCollector } from '@backstage/test-utils'; + +const mockDir = createMockDirectory(); + +jest.mock('../paths', () => ({ + paths: { resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args) }, +})); + +const mockYarnCreate = jest.spyOn(Yarn, 'create'); + +describe('PackageManager', () => { + describe('detectPackageManager', () => { + describe('yarn', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should detect via yarn.lock', async () => { + mockDir.setContent({ + 'yarn.lock': 'just needs to exist', + }); + await detectPackageManager(); + expect(mockYarnCreate).toHaveBeenCalled(); + }); + + it('should detect via root package.json workspaces', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + workspaces: { + packages: [], + }, + }), + }); + await detectPackageManager(); + expect(mockYarnCreate).toHaveBeenCalled(); + }); + + it('should detect via root package.json packageManager', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + packageManager: 'yarn@1.0.0', + }), + }); + await detectPackageManager(); + expect(mockYarnCreate).toHaveBeenCalled(); + }); + + it('should fall back to yarn when root package.json packageManager field is unrecognized', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + packageManager: 'something-else@1.0.0', + }), + }); + + const { log } = await withLogCollector(async () => { + await detectPackageManager(); + }); + + expect(mockYarnCreate).toHaveBeenCalledTimes(1); + expect(log).toHaveLength(1); + }); + + it('should fall back to yarn with available detection methods', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + }), + }); + + const { log } = await withLogCollector(async () => { + await detectPackageManager(); + }); + + expect(mockYarnCreate).toHaveBeenCalledTimes(1); + expect(log).toHaveLength(1); + }); + + it('should fall back to yarn with no package.json at all', async () => { + mockDir.setContent({}); + + const { log } = await withLogCollector(async () => { + await detectPackageManager(); + }); + + expect(mockYarnCreate).toHaveBeenCalledTimes(1); + // extra log for the error reading package.json + expect(log).toHaveLength(2); + }); + }); + }); +}); diff --git a/packages/cli-node/src/pacman/PackageManager.ts b/packages/cli-node/src/pacman/PackageManager.ts new file mode 100644 index 0000000000..793828e1df --- /dev/null +++ b/packages/cli-node/src/pacman/PackageManager.ts @@ -0,0 +1,138 @@ +/* + * 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 { Yarn } from './yarn'; +import { Lockfile } from './Lockfile'; +import { SpawnOptionsPartialEnv } from '../run'; +import { paths } from '../paths'; +import fs from 'fs-extra'; + +/** + * Package info retrieved from the package manager, usually from NPM. + * + * @public + */ +export type PackageInfo = { + name: string; + 'dist-tags': Record; + versions: string[]; + time: { [version: string]: string }; +}; + +/** + * Represents the package manager in use by this instance of Backstage. This + * interface allows Backstage adopters to change the package manager used by + * their repo and still use the Backstage CLI, and it's helpful tooling. + * + * @public + */ +export interface PackageManager { + /** The name of the package manager. */ + name(): string; + + /** The self-reported version of the package manager. */ + version(): string; + + /** The file name of the lockfile used by the package manager. */ + lockfileName(): string; + + /** + * If this repo is a monorepo, returns the patterns specified by the package + * manager's monorepo configuration. Does not attempt to resolve any globs. + */ + getMonorepoPackages(): Promise; + + /** Uses the package manager to run a command in the repo. */ + run(args: string[], options?: SpawnOptionsPartialEnv): Promise; + + /** + * Executes the package manager's pack command to bundle the repo into an + * archive. + */ + pack(output: string, packageDir: string): Promise; + + /** Fetches information about the given package, usually from NPM. */ + fetchPackageInfo(name: string): Promise; + + /** Reads the lockfile from the repo. See {@link Lockfile} */ + loadLockfile(): Promise; + + /** Parses the given string as a {@link Lockfile}. */ + parseLockfile(contents: string): Promise; + + /** + * Whether the package manager supports the 'backstage:^' version protocol. + */ + supportsBackstageVersionProtocol(): Promise; + + /** A string representation of the package manager. */ + toString(): string; +} + +/** + * Uses several mechanisms to detect the currently used package manager. The + * detection methods are intended to be ordered roughly from fastest to slowest + * in order to make this method as fast as possible. + * + * @public + */ +export async function detectPackageManager(): Promise { + const hasYarnLockfile = await fileExists( + paths.resolveTargetRoot('yarn.lock'), + ); + if (hasYarnLockfile) { + return await Yarn.create(); + } + + try { + const packageJson = await fs.readJson( + paths.resolveTargetRoot('package.json'), + ); + if (packageJson.workspaces) { + // technically this could be NPM as well + return await Yarn.create(); + } + + const declaredPacman = packageJson.packageManager; + if (declaredPacman) { + const [name, _version] = declaredPacman.split('@'); + switch (name) { + case 'yarn': + return await Yarn.create(); + default: + console.log(`Detected unsupported package manager: ${name}.`); + return await Yarn.create(); + } + } + } catch (error) { + console.log(`Error during package manager detection: ${error}`); + } + + // currently yarn is the only package manager supported so just log an error and use it anyway + console.log( + 'Yarn was not detected, but is the only supported package manager.', + ); + return await Yarn.create(); +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch (error) { + return false; + } +} diff --git a/packages/cli-node/src/pacman/index.ts b/packages/cli-node/src/pacman/index.ts new file mode 100644 index 0000000000..4e8974d5f8 --- /dev/null +++ b/packages/cli-node/src/pacman/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ +export type { PackageManager, PackageInfo } from './PackageManager'; +export type { + Lockfile, + LockfileEntry, + LockfileDiff, + LockfileDiffEntry, +} from './Lockfile'; + +export { detectPackageManager } from './PackageManager'; diff --git a/packages/cli-node/src/pacman/yarn/Yarn.test.ts b/packages/cli-node/src/pacman/yarn/Yarn.test.ts new file mode 100644 index 0000000000..d12705ce24 --- /dev/null +++ b/packages/cli-node/src/pacman/yarn/Yarn.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { createMockDirectory } from '@backstage/backend-test-utils'; +import { Yarn } from './Yarn'; + +const mockDir = createMockDirectory(); + +jest.mock('../../paths', () => ({ + paths: { resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args) }, +})); + +const yarnClassic = new Yarn({ version: '1.0.0', codename: 'classic' }); +const yarnBerry = new Yarn({ version: '3.0.0', codename: 'berry' }); +const allYarnVersions = [yarnClassic, yarnBerry]; + +describe('Yarn', () => { + describe.each(allYarnVersions)('%s.getMonorepoPackages', yarn => { + it('should detect a monorepo', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + workspaces: { + packages: ['packages/*'], + }, + }), + }); + await expect(yarn.getMonorepoPackages()).resolves.toEqual(['packages/*']); + }); + + it('should detect a non-monorepo', async () => { + mockDir.setContent({ + 'package.json': JSON.stringify({ + name: 'foo', + }), + }); + await expect(yarn.getMonorepoPackages()).resolves.toEqual([]); + }); + + it('should return false if package.json is missing', async () => { + mockDir.setContent({}); + await expect(yarn.getMonorepoPackages()).resolves.toEqual([]); + }); + }); +}); diff --git a/packages/cli-node/src/pacman/yarn/Yarn.ts b/packages/cli-node/src/pacman/yarn/Yarn.ts new file mode 100644 index 0000000000..d3c1835534 --- /dev/null +++ b/packages/cli-node/src/pacman/yarn/Yarn.ts @@ -0,0 +1,122 @@ +/* + * 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 { + assertError, + ForwardedError, + NotImplementedError, +} from '@backstage/errors'; +import { PackageInfo, PackageManager } from '../PackageManager'; +import { Lockfile } from '../Lockfile'; +import { YarnVersion } from './types'; +import { paths } from '../../paths'; +import fs from 'fs-extra'; +import { run, execFile, SpawnOptionsPartialEnv } from '../../run'; + +export class Yarn implements PackageManager { + constructor(private readonly yarnVersion: YarnVersion) {} + + static async create(dir?: string): Promise { + const yarnVersion = await detectYarnVersion(dir); + return new Yarn(yarnVersion); + } + + name() { + return 'yarn'; + } + + version() { + return this.yarnVersion.version; + } + + lockfileName(): string { + return 'yarn.lock'; + } + + async getMonorepoPackages() { + const rootPackageJsonPath = paths.resolveTargetRoot('package.json'); + try { + const pkg = await fs.readJson(rootPackageJsonPath); + return pkg?.workspaces?.packages || []; + } catch (error) { + return []; + } + } + + async pack(out: string, packageDir: string) { + const outArg = + this.yarnVersion.codename === 'classic' ? '--filename' : '--out'; + await this.run(['pack', outArg, out], { + cwd: packageDir, + }); + } + + async run(args: string[], options?: SpawnOptionsPartialEnv) { + await run('yarn', args, options); + } + + async fetchPackageInfo(): Promise { + throw new NotImplementedError(); + } + + async loadLockfile(): Promise { + throw new NotImplementedError(); + } + + async parseLockfile(): Promise { + throw new NotImplementedError(); + } + + async supportsBackstageVersionProtocol(): Promise { + throw new NotImplementedError(); + } + + toString(): string { + return `${this.name()}@${this.yarnVersion.version}`; + } +} + +const versions = new Map>(); + +function detectYarnVersion(dir?: string): Promise { + 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, + }); + const versionString = stdout.trim(); + const codename: 'classic' | 'berry' = versionString.startsWith('1.') + ? 'classic' + : 'berry'; + return { version: versionString, codename }; + } 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; +} diff --git a/packages/cli-node/src/pacman/yarn/index.ts b/packages/cli-node/src/pacman/yarn/index.ts new file mode 100644 index 0000000000..a0998d50c4 --- /dev/null +++ b/packages/cli-node/src/pacman/yarn/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { Yarn } from './Yarn'; diff --git a/packages/cli-node/src/pacman/yarn/types.ts b/packages/cli-node/src/pacman/yarn/types.ts new file mode 100644 index 0000000000..f7ba98f706 --- /dev/null +++ b/packages/cli-node/src/pacman/yarn/types.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ +export type YarnVersion = { + codename: 'classic' | 'berry'; + version: string; +}; diff --git a/packages/cli-node/src/util.ts b/packages/cli-node/src/util.ts index 727c173b00..eaf05855b1 100644 --- a/packages/cli-node/src/util.ts +++ b/packages/cli-node/src/util.ts @@ -14,11 +14,96 @@ * limitations under the License. */ -import { execFile as execFileCb } from 'child_process'; +import { + ChildProcess, + execFile as execFileCb, + spawn, + SpawnOptions, +} from 'child_process'; import { promisify } from 'util'; import { findPaths } from '@backstage/cli-common'; +import { ExitCodeError } from './errors'; export const execFile = promisify(execFileCb); /* eslint-disable-next-line no-restricted-syntax */ export const paths = findPaths(__dirname); + +/** + * A function that can be used to log data from a child process + * + * @public + */ +export type LogFunc = (data: Buffer) => void; + +/** + * Options for running a child process + * + * @public + */ +export type SpawnOptionsPartialEnv = Omit & { + env?: Partial; + // Pipe stdout to this log function + stdoutLogFunc?: LogFunc; + // Pipe stderr to this log function + stderrLogFunc?: LogFunc; +}; + +// Runs a child command, returning a promise that is only resolved if the child exits with code 0. +export async function run( + name: string, + args: string[] = [], + options: SpawnOptionsPartialEnv = {}, +) { + const { stdoutLogFunc, stderrLogFunc } = options; + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: 'true', + ...(options.env ?? {}), + }; + + const stdio = [ + 'inherit', + stdoutLogFunc ? 'pipe' : 'inherit', + stderrLogFunc ? 'pipe' : 'inherit', + ] as ('inherit' | 'pipe')[]; + + const child = spawn(name, args, { + stdio, + shell: true, + ...options, + env, + }); + + if (stdoutLogFunc && child.stdout) { + child.stdout.on('data', stdoutLogFunc); + } + if (stderrLogFunc && child.stderr) { + child.stderr.on('data', stderrLogFunc); + } + + await waitForExit(child, name); +} + +async function waitForExit( + child: ChildProcess & { exitCode: number | null }, + name?: string, +): Promise { + if (typeof child.exitCode === 'number') { + if (child.exitCode) { + throw new ExitCodeError(child.exitCode, name); + } + return; + } + + await new Promise((resolve, reject) => { + child.once('error', error => reject(error)); + child.once('exit', code => { + if (code) { + reject(new ExitCodeError(code, name)); + } else { + resolve(); + } + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 118074f40a..cda2b4bbc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3844,6 +3844,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": ^1.1.3 "@yarnpkg/parsers": ^3.0.0