cli-node, cli: move yarnPlugin and SuccessCache to cli-node
Move `getHasYarnPlugin` and `SuccessCache` from `@backstage/cli` internal modules to `@backstage/cli-node` as public exports, making them available for reuse by other CLI tooling. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
-103
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli';
|
||||
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 3600_000;
|
||||
|
||||
export class SuccessCache {
|
||||
readonly #path: string;
|
||||
|
||||
/**
|
||||
* Trim any occurrences of the workspace root path from the input string. This
|
||||
* is useful to ensure stable hashes that don't vary based on the workspace
|
||||
* location.
|
||||
*/
|
||||
static trimPaths(input: string) {
|
||||
return input.replaceAll(targetPaths.rootDir, '');
|
||||
}
|
||||
|
||||
constructor(name: string, basePath?: string) {
|
||||
this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name);
|
||||
}
|
||||
|
||||
async read(): Promise<Set<string>> {
|
||||
try {
|
||||
const stat = await fs.stat(this.#path);
|
||||
if (!stat.isDirectory()) {
|
||||
await fs.rm(this.#path);
|
||||
return new Set();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return new Set();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const items = await fs.readdir(this.#path);
|
||||
|
||||
const returned = new Set<string>();
|
||||
const removed = new Set<string>();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const item of items) {
|
||||
const split = item.split('_');
|
||||
if (split.length !== 2) {
|
||||
removed.add(item);
|
||||
continue;
|
||||
}
|
||||
const createdAt = parseInt(split[0], 10);
|
||||
if (Number.isNaN(createdAt) || now - createdAt > CACHE_MAX_AGE_MS) {
|
||||
removed.add(item);
|
||||
} else {
|
||||
returned.add(split[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of removed) {
|
||||
await fs.unlink(resolvePath(this.#path, item));
|
||||
}
|
||||
|
||||
return returned;
|
||||
}
|
||||
|
||||
async write(newEntries: Iterable<string>): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
await fs.ensureDir(this.#path);
|
||||
|
||||
const existingItems = await fs.readdir(this.#path);
|
||||
|
||||
const empty = Buffer.alloc(0);
|
||||
for (const key of newEntries) {
|
||||
// Remove any existing items with the key we're about to add
|
||||
const trimmedItems = existingItems.filter(item =>
|
||||
item.endsWith(`_${key}`),
|
||||
);
|
||||
for (const trimmedItem of trimmedItems) {
|
||||
await fs.unlink(resolvePath(this.#path, trimmedItem));
|
||||
}
|
||||
|
||||
await fs.writeFile(resolvePath(this.#path, `${now}_${key}`), empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* 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 { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import { getHasYarnPlugin } from './yarnPlugin';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('getHasYarnPlugin', () => {
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
|
||||
it('should return false when .yarnrc.yml does not exist', async () => {
|
||||
mockDir.setContent({});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when .yarnrc.yml is empty', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': '',
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugins array is empty', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': 'plugins: []',
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugins array does not contain backstage plugin', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when backstage plugin is present', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when backstage plugin is the only plugin', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when .yarnrc.yml has invalid content', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': 'invalid: yaml: content: [',
|
||||
});
|
||||
|
||||
await expect(getHasYarnPlugin()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when .yarnrc.yml has unexpected structure', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins: "not an array"
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(getHasYarnPlugin()).rejects.toThrow(
|
||||
'Unexpected content in .yarnrc.yml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle plugins with different structure', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import z from 'zod';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const yarnRcSchema = z.object({
|
||||
plugins: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Detects whether the Backstage Yarn plugin is installed in the target repository.
|
||||
*
|
||||
* @returns Promise<boolean> - true if the plugin is installed, false otherwise
|
||||
*/
|
||||
export async function getHasYarnPlugin(): Promise<boolean> {
|
||||
const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml');
|
||||
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
|
||||
if (e.code === 'ENOENT') {
|
||||
// gracefully continue in case the file doesn't exist
|
||||
return '';
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (!yarnRcContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parseResult = yarnRcSchema.safeParse(yaml.parse(yarnRcContent));
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected content in .yarnrc.yml: ${parseResult.error.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const yarnRc = parseResult.data;
|
||||
|
||||
const backstagePlugin = yarnRc.plugins?.some(
|
||||
plugin => plugin.path === '.yarn/plugins/@yarnpkg/plugin-backstage.cjs',
|
||||
);
|
||||
|
||||
return Boolean(backstagePlugin);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createScriptOptionsParser } from '../../lib/optionsParser';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
import { SuccessCache } from '@backstage/cli-node';
|
||||
|
||||
function depCount(pkg: BackstagePackageJson) {
|
||||
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
|
||||
|
||||
@@ -30,8 +30,11 @@ import { OptionValues } from 'commander';
|
||||
import { isError, NotFoundError } from '@backstage/errors';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import { Lockfile, runConcurrentTasks } from '@backstage/cli-node';
|
||||
import {
|
||||
getHasYarnPlugin,
|
||||
Lockfile,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import {
|
||||
fetchPackageInfo,
|
||||
mapDependencies,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Lockfile } from '@backstage/cli-node';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createPackageVersionProvider } from '../../../../lib/version';
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import { getHasYarnPlugin } from '@backstage/cli-node';
|
||||
|
||||
const builtInHelpers = {
|
||||
camelCase,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
findOwnPaths,
|
||||
isChildPath,
|
||||
} from '@backstage/cli-common';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
import { SuccessCache } from '@backstage/cli-node';
|
||||
|
||||
type JestProject = {
|
||||
displayName: string;
|
||||
|
||||
Reference in New Issue
Block a user