diff --git a/packages/cli-common/.eslintrc.js b/packages/cli-common/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/cli-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/cli-common/README.md b/packages/cli-common/README.md new file mode 100644 index 0000000000..3488003a76 --- /dev/null +++ b/packages/cli-common/README.md @@ -0,0 +1,12 @@ +# @backstage/cli-common + +This package provides config loading functionality used by the backend, and CLI. + +## Installation + +Do not install this package directly, it is an internal package used by [@backstage/cli](https://www.npmjs.com/package/@backstage/cli), and [@backstage/create-app](https://www.npmjs.com/package/@backstage/create-app). Depend on either of those instead. + +## Documentation + +- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json new file mode 100644 index 0000000000..1d2dc35cd9 --- /dev/null +++ b/packages/cli-common/package.json @@ -0,0 +1,39 @@ +{ + "name": "@backstage/cli-common", + "description": "Common functionality used by cli, backend, and create-app", + "version": "0.1.1-alpha.16", + "private": false, + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/cli-common" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.16", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/cli-common/src/index.ts b/packages/cli-common/src/index.ts new file mode 100644 index 0000000000..a080f49b6b --- /dev/null +++ b/packages/cli-common/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { findPaths } from './paths'; +export type { Paths } from './paths'; diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts new file mode 100644 index 0000000000..087f95af38 --- /dev/null +++ b/packages/cli-common/src/paths.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { resolve as resolvePath } from 'path'; +import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths'; + +describe('paths', () => { + it('findOwnDir and findOwnRootDir should find owns paths', () => { + const dir = findOwnDir(__dirname); + const root = findOwnRootDir(dir); + + expect(dir).toBe(resolvePath(__dirname, '..')); + expect(root).toBe(resolvePath(__dirname, '../../..')); + }); + + it('findRootPath should find a root path', () => { + expect(findRootPath(__dirname, () => true)).toBe( + resolvePath(__dirname, '..'), + ); + + expect(findRootPath(__dirname, () => false)).toBeUndefined(); + + expect( + findRootPath( + __dirname, + path => path !== resolvePath(__dirname, '../package.json'), + ), + ).toBe(resolvePath(__dirname, '../../..')); + }); + + it('findPaths should find package paths', () => { + const dir = resolvePath(__dirname, '..'); + const root = resolvePath(__dirname, '../../..'); + + const paths = findPaths(__dirname); + + expect(paths.ownDir).toBe(dir); + expect(paths.ownRoot).toBe(root); + expect(paths.resolveOwn('./derp.txt')).toBe(resolvePath(dir, 'derp.txt')); + expect(paths.resolveOwnRoot('./derp.txt')).toBe( + resolvePath(root, 'derp.txt'), + ); + expect(paths.targetDir).toBe(dir); + expect(paths.targetRoot).toBe(root); + expect(paths.resolveTarget('./derp.txt')).toBe( + resolvePath(dir, 'derp.txt'), + ); + expect(paths.resolveTargetRoot('./derp.txt')).toBe( + resolvePath(root, 'derp.txt'), + ); + }); + + it('findPaths should find mocked package paths', () => { + const mockCwd = resolvePath(__dirname, '../../core'); + const mockDir = resolvePath(__dirname, '../../cli'); + const root = resolvePath(__dirname, '../../..'); + + jest.spyOn(process, 'cwd').mockReturnValue(mockCwd); + + const paths = findPaths(resolvePath(mockDir, 'src/lib')); + + expect(paths.ownDir).toBe(mockDir); + expect(paths.ownRoot).toBe(root); + expect(paths.resolveOwn('./derp.txt')).toBe( + resolvePath(mockDir, 'derp.txt'), + ); + expect(paths.resolveOwnRoot('./derp.txt')).toBe( + resolvePath(root, 'derp.txt'), + ); + expect(paths.targetDir).toBe(mockCwd); + expect(paths.targetRoot).toBe(root); + expect(paths.resolveTarget('./derp.txt')).toBe( + resolvePath(mockCwd, 'derp.txt'), + ); + expect(paths.resolveTargetRoot('./derp.txt')).toBe( + resolvePath(root, 'derp.txt'), + ); + }); +}); diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts new file mode 100644 index 0000000000..70761ad2d7 --- /dev/null +++ b/packages/cli-common/src/paths.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * 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'; +import { dirname, resolve as resolvePath } from 'path'; + +export type ResolveFunc = (...paths: string[]) => string; + +// Common paths and resolve functions used by the cli. +// Currently assumes it is being executed within a monorepo. +export type Paths = { + // Root dir of the cli itself, containing package.json + ownDir: string; + + // Monorepo root dir of the cli itself. Only accessible when running inside Backstage repo. + ownRoot: string; + + // The location of the app that the cli is being executed in + targetDir: string; + + // The monorepo root package of the app that the cli is being executed in. + targetRoot: string; + + // Resolve a path relative to own repo + resolveOwn: ResolveFunc; + + // Resolve a path relative to own monorepo root. Only accessible when running inside Backstage repo. + resolveOwnRoot: ResolveFunc; + + // Resolve a path relative to the app + resolveTarget: ResolveFunc; + + // Resolve a path relative to the app repo root + resolveTargetRoot: ResolveFunc; +}; + +// Looks for a package.json with a workspace config to identify the root of the monorepo +export function findRootPath( + searchDir: string, + filterFunc: (pkgJsonPath: string) => boolean, +): string | undefined { + let path = searchDir; + + // Some sanity check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + const exists = fs.existsSync(packagePath); + if (exists && filterFunc(packagePath)) { + return path; + } + + const newPath = dirname(path); + if (newPath === path) { + return undefined; + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for root package.json at ${searchDir}`, + ); +} + +// Finds the root of a given package +export function findOwnDir(searchDir: string) { + const path = findRootPath(searchDir, () => true); + if (!path) { + throw new Error( + `No package.json found while searching for package root of ${searchDir}`, + ); + } + return path; +} + +// Finds the root of the monorepo that the package exists in. Only accessible when running inside Backstage repo. +export function findOwnRootDir(ownDir: string) { + const isLocal = fs.existsSync(resolvePath(ownDir, 'src')); + if (!isLocal) { + throw new Error( + 'Tried to access monorepo package root dir outside of Backstage repository', + ); + } + + return resolvePath(ownDir, '../..'); +} + +/** + * Find paths related to a package and its execution context. + * + * @example + * + * const paths = findPaths(__dirname) + */ +export function findPaths(searchDir: string): Paths { + const ownDir = findOwnDir(searchDir); + const targetDir = fs.realpathSync(process.cwd()); + + // Lazy load this as it will throw an error if we're not inside the Backstage repo. + let ownRoot = ''; + const getOwnRoot = () => { + if (!ownRoot) { + ownRoot = findOwnRootDir(ownDir); + } + return ownRoot; + }; + + // We're not always running in a monorepo, so we lazy init this to only crash commands + // that require a monorepo when we're not in one. + let targetRoot = ''; + const getTargetRoot = () => { + if (!targetRoot) { + targetRoot = + findRootPath(targetDir, path => { + try { + const content = fs.readFileSync(path, 'utf8'); + const data = JSON.parse(content); + return Boolean(data.workspaces?.packages); + } catch (error) { + throw new Error( + `Failed to parse package.json file while searching for root, ${error}`, + ); + } + }) ?? targetDir; // We didn't find any root package.json, assume we're not in a monorepo + } + return targetRoot; + }; + + return { + ownDir, + get ownRoot() { + return getOwnRoot(); + }, + targetDir, + get targetRoot() { + return getTargetRoot(); + }, + resolveOwn: (...paths) => resolvePath(ownDir, ...paths), + resolveOwnRoot: (...paths) => resolvePath(getOwnRoot(), ...paths), + resolveTarget: (...paths) => resolvePath(targetDir, ...paths), + resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths), + }; +}