add minimal PackageManager and Lockfile interfaces

Signed-off-by: Tyler Davis <tylerd@canva.com>
This commit is contained in:
Tyler Davis
2024-10-20 14:59:03 +11:00
parent 6e6db67d2f
commit af665eadb8
14 changed files with 695 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-node': patch
---
add PackageManager and Lockfile interfaces for future use
+1
View File
@@ -75,6 +75,7 @@ typings/
# Node version directives
.nvmrc
.tool-versions
# Optional eslint cache
.eslintcache
+2 -1
View File
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
"@backstage/cli": "workspace:^",
"@backstage/test-utils": "workspace:^"
}
}
+32
View File
@@ -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 {}
+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.
*/
/**
* 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<string>;
/**
* 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<string, Set<string>>;
/**
* 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;
}
@@ -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);
});
});
});
});
@@ -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<string, string>;
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<string[]>;
/** Uses the package manager to run a command in the repo. */
run(args: string[], options?: SpawnOptionsPartialEnv): Promise<void>;
/**
* Executes the package manager's pack command to bundle the repo into an
* archive.
*/
pack(output: string, packageDir: string): Promise<void>;
/** Fetches information about the given package, usually from NPM. */
fetchPackageInfo(name: string): Promise<PackageInfo>;
/** Reads the lockfile from the repo. See {@link Lockfile} */
loadLockfile(): Promise<Lockfile>;
/** Parses the given string as a {@link Lockfile}. */
parseLockfile(contents: string): Promise<Lockfile>;
/**
* Whether the package manager supports the 'backstage:^' version protocol.
*/
supportsBackstageVersionProtocol(): Promise<boolean>;
/** 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<PackageManager> {
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<boolean> {
try {
await fs.access(filePath);
return true;
} catch (error) {
return false;
}
}
+24
View File
@@ -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';
@@ -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([]);
});
});
});
+122
View File
@@ -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<Yarn> {
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<PackageInfo> {
throw new NotImplementedError();
}
async loadLockfile(): Promise<Lockfile> {
throw new NotImplementedError();
}
async parseLockfile(): Promise<Lockfile> {
throw new NotImplementedError();
}
async supportsBackstageVersionProtocol(): Promise<boolean> {
throw new NotImplementedError();
}
toString(): string {
return `${this.name()}@${this.yarnVersion.version}`;
}
}
const versions = new Map<string, Promise<YarnVersion>>();
function detectYarnVersion(dir?: string): Promise<YarnVersion> {
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;
}
@@ -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';
@@ -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;
};
+86 -1
View File
@@ -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<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
// 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<void> {
if (typeof child.exitCode === 'number') {
if (child.exitCode) {
throw new ExitCodeError(child.exitCode, name);
}
return;
}
await new Promise<void>((resolve, reject) => {
child.once('error', error => reject(error));
child.once('exit', code => {
if (code) {
reject(new ExitCodeError(code, name));
} else {
resolve();
}
});
});
}
+1
View File
@@ -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