From a43d29eb9d307d89ad0a38ce426d2ba22df32a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20DOREAU?= Date: Sat, 18 Jul 2020 17:02:09 +0200 Subject: [PATCH] refactor(cli): move create-app into his own package --- packages/cli/src/index.ts | 11 -- packages/create-app/.eslintrc.js | 7 + packages/create-app/README.md | 22 +++ packages/create-app/bin/backstage-create-app | 34 ++++ packages/create-app/package.json | 44 +++++ .../src}/createApp.ts | 8 +- packages/create-app/src/index.ts | 69 ++++++++ packages/create-app/src/lib/errors.ts | 46 ++++++ packages/create-app/src/lib/paths.ts | 150 ++++++++++++++++++ packages/create-app/src/lib/tasks.ts | 105 ++++++++++++ packages/create-app/src/lib/version.ts | 26 +++ 11 files changed, 507 insertions(+), 15 deletions(-) create mode 100644 packages/create-app/.eslintrc.js create mode 100644 packages/create-app/README.md create mode 100755 packages/create-app/bin/backstage-create-app create mode 100644 packages/create-app/package.json rename packages/{cli/src/commands/create-app => create-app/src}/createApp.ts (95%) create mode 100644 packages/create-app/src/index.ts create mode 100644 packages/create-app/src/lib/errors.ts create mode 100644 packages/create-app/src/lib/paths.ts create mode 100644 packages/create-app/src/lib/tasks.ts create mode 100644 packages/create-app/src/lib/version.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7521549d96..3123cda613 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,17 +22,6 @@ import { version } from './lib/version'; const main = (argv: string[]) => { program.name('backstage-cli').version(version); - program - .command('create-app') - .description('Creates a new app in a new directory') - .option( - '--skip-install', - 'Skip the install and builds steps after creating the app', - ) - .action( - lazyAction(() => import('./commands/create-app/createApp'), 'default'), - ); - program .command('app:build') .description('Build an app for a production release') diff --git a/packages/create-app/.eslintrc.js b/packages/create-app/.eslintrc.js new file mode 100644 index 0000000000..69bec6cd2a --- /dev/null +++ b/packages/create-app/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], + ignorePatterns: ['templates/**'], + rules: { + 'no-console': 0, + }, +}; diff --git a/packages/create-app/README.md b/packages/create-app/README.md new file mode 100644 index 0000000000..da53c6dd89 --- /dev/null +++ b/packages/create-app/README.md @@ -0,0 +1,22 @@ +# @backstage/create-app + +This package provides a CLI for creating apps. + +## Installation + +Install the package via npm or yarn: + +```sh +$ npm install --save @backstage/create-app +``` + +or + +```sh +$ yarn add @backstage/create-app +``` + +## 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/create-app/bin/backstage-create-app b/packages/create-app/bin/backstage-create-app new file mode 100755 index 0000000000..c9baf5d57c --- /dev/null +++ b/packages/create-app/bin/backstage-create-app @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/* + * 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. + */ + +const path = require('path'); + +// Figure out whether we're running inside the backstage repo or as an installed dependency +const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); + +if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { + require('../src'); +} else { + require('ts-node').register({ + transpileOnly: true, + compilerOptions: { + module: 'CommonJS', + }, + }); + + require('../src'); +} diff --git a/packages/create-app/package.json b/packages/create-app/package.json new file mode 100644 index 0000000000..4458daaedf --- /dev/null +++ b/packages/create-app/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/create-app", + "description": "Create app package for Backstage", + "version": "0.1.1", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/create-app" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "bin": { + "backstage-create-app": "bin/backstage-create-app" + }, + "dependencies": { + "commander": "^4.1.1", + "chalk": "^4.0.0", + "fs-extra": "^9.0.0", + "react-dev-utils": "^10.2.1", + "inquirer": "^7.0.4", + "recursive-readdir": "^2.2.2", + "ora": "^4.0.3" + }, + "devDependencies": { + "@types/fs-extra": "^9.0.1", + "@types/react-dev-utils": "^9.0.4", + "@types/inquirer": "^6.5.0", + "@types/recursive-readdir": "^2.2.0", + "@types/ora": "^3.2.0", + "ts-node": "^8.6.2" + }, + "nodemonConfig": { + "watch": "./src", + "ext": "ts" + } +} diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/create-app/src/createApp.ts similarity index 95% rename from packages/cli/src/commands/create-app/createApp.ts rename to packages/create-app/src/createApp.ts index b3ddcd3109..ebdd7583da 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -22,9 +22,9 @@ import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import os from 'os'; -import { Task, templatingTask } from '../../lib/tasks'; -import { paths } from '../../lib/paths'; -import { version } from '../../lib/version'; +import { Task, templatingTask } from './lib/tasks'; +import { paths } from './lib/paths'; +import { version } from './lib/version'; const exec = promisify(execCb); @@ -116,7 +116,7 @@ export default async (cmd: Command): Promise => { answers.dbTypePG = answers.dbType === 'PostgreSQL'; answers.dbTypeSqlite = answers.dbType === 'SQLite'; - const templateDir = paths.resolveOwn('templates/default-app'); + const templateDir = paths.resolveOwnRoot('packages/cli/templates/default-app'); const tempDir = resolvePath(os.tmpdir(), answers.name); const appDir = resolvePath(paths.targetDir, answers.name); diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts new file mode 100644 index 0000000000..2c2106bec2 --- /dev/null +++ b/packages/create-app/src/index.ts @@ -0,0 +1,69 @@ +/* + * 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 program from 'commander'; +import chalk from 'chalk'; +import { exitWithError } from './lib/errors'; +import { version } from './lib/version'; + +const main = (argv: string[]) => { + program.name('backstage-create-app').version(version); + + program + .description('Creates a new app in a new directory') + .option( + '--skip-install', + 'Skip the install and builds steps after creating the app', + ) + .action( + lazyAction(() => import('./createApp'), 'default'), + ); + + if (!process.argv.slice(2).length) { + program.outputHelp(chalk.yellow); + } + + program.parse(argv); +}; + +// Wraps an action function so that it always exits and handles errors +function lazyAction( + actionRequireFunc: () => Promise< + { [name in Export]: (...args: T) => Promise } + >, + exportName: Export, +): (...args: T) => Promise { + return async (...args: T) => { + try { + const module = await actionRequireFunc(); + const actionFunc = module[exportName]; + await actionFunc(...args); + process.exit(0); + } catch (error) { + exitWithError(error); + } + }; +} + +process.on('unhandledRejection', rejection => { + if (rejection instanceof Error) { + exitWithError(rejection); + } else { + exitWithError(new Error(`Unknown rejection: '${rejection}'`)); + } +}); + +main(process.argv); diff --git a/packages/create-app/src/lib/errors.ts b/packages/create-app/src/lib/errors.ts new file mode 100644 index 0000000000..a1eab4c9e5 --- /dev/null +++ b/packages/create-app/src/lib/errors.ts @@ -0,0 +1,46 @@ +/* + * 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 chalk from 'chalk'; + +export class CustomError extends Error { + get name(): string { + return this.constructor.name; + } +} + +export class ExitCodeError extends CustomError { + readonly code: number; + + constructor(code: number, command?: string) { + if (command) { + super(`Command '${command}' exited with code ${code}`); + } else { + super(`Child exited with code ${code}`); + } + this.code = code; + } +} + +export function exitWithError(error: Error): never { + if (error instanceof ExitCodeError) { + process.stderr.write(`\n${chalk.red(error.message)}\n\n`); + process.exit(error.code); + } else { + process.stderr.write(`\n${chalk.red(`${error}`)}\n\n`); + process.exit(1); + } +} diff --git a/packages/create-app/src/lib/paths.ts b/packages/create-app/src/lib/paths.ts new file mode 100644 index 0000000000..be3f3dcee8 --- /dev/null +++ b/packages/create-app/src/lib/paths.ts @@ -0,0 +1,150 @@ +/* + * 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-extra'; +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 that has name: "root" to identify the root of the monorepo +export function findRootPath(topPath: string): string { + let path = topPath; + + // Some sanity check to avoid infinite loop + for (let i = 0; i < 1000; i++) { + const packagePath = resolvePath(path, 'package.json'); + const exists = fs.pathExistsSync(packagePath); + if (exists) { + try { + const data = fs.readJsonSync(packagePath); + if (data.name === 'root' || data.name.includes('backstage-e2e')) { + return path; + } + } catch (error) { + throw new Error( + `Failed to parse package.json file while searching for root, ${error}`, + ); + } + } + + const newPath = dirname(path); + if (newPath === path) { + throw new Error( + `No package.json with name "root" found as a parent of ${topPath}`, + ); + } + path = newPath; + } + + throw new Error( + `Iteration limit reached when searching for root package.json at ${topPath}`, + ); +} + +// Finds the root of the cli package itself +export function findOwnDir() { + // Known relative locations of package in dist/dev + const pathDist = '..'; + const pathDev = '../..'; + + // Check the closest dir first + const pkgInDist = resolvePath(__dirname, pathDist, 'package.json'); + const isDist = fs.pathExistsSync(pkgInDist); + + const path = isDist ? pathDist : pathDev; + return resolvePath(__dirname, path); +} + +// Finds the root of the monorepo that the cli exists in. Only accessible when running inside Backstage repo. +export function findOwnRootPath(ownDir: string) { + const isLocal = fs.pathExistsSync(resolvePath(ownDir, 'src')); + if (!isLocal) { + throw new Error( + 'Tried to access monorepo package root dir outside of Backstage repository', + ); + } + + return resolvePath(ownDir, '../..'); +} + +export function findPaths(): Paths { + const ownDir = findOwnDir(); + 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 = findOwnRootPath(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); + } + 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), + }; +} + +export const paths = findPaths(); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts new file mode 100644 index 0000000000..0753301b78 --- /dev/null +++ b/packages/create-app/src/lib/tasks.ts @@ -0,0 +1,105 @@ +/* + * 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 chalk from 'chalk'; +import fs from 'fs-extra'; +import handlebars from 'handlebars'; +import ora from 'ora'; +import { basename, dirname } from 'path'; +import recursive from 'recursive-readdir'; + +const TASK_NAME_MAX_LENGTH = 14; + +export class Task { + static log(name: string = '') { + process.stdout.write(`${chalk.green(name)}\n`); + } + + static error(message: string = '') { + process.stdout.write(`\n${chalk.red(message)}\n\n`); + } + + static section(name: string) { + const title = chalk.green(`${name}:`); + process.stdout.write(`\n ${title}\n`); + } + + static exit(code: number = 0) { + process.exit(code); + } + + static async forItem( + task: string, + item: string, + taskFunc: () => Promise, + ): Promise { + const paddedTask = chalk.green(task.padEnd(TASK_NAME_MAX_LENGTH)); + + const spinner = ora({ + prefixText: chalk.green(` ${paddedTask}${chalk.cyan(item)}`), + spinner: 'arc', + color: 'green', + }).start(); + + try { + await taskFunc(); + spinner.succeed(); + } catch (error) { + spinner.fail(); + throw error; + } + } +} + +export async function templatingTask( + templateDir: string, + destinationDir: string, + context: any, +) { + const files = await recursive(templateDir).catch(error => { + throw new Error(`Failed to read template directory: ${error.message}`); + }); + + for (const file of files) { + const destinationFile = file.replace(templateDir, destinationDir); + await fs.ensureDir(dirname(destinationFile)); + + if (file.endsWith('.hbs')) { + await Task.forItem('templating', basename(file), async () => { + const destination = destinationFile.replace(/\.hbs$/, ''); + + const template = await fs.readFile(file); + const compiled = handlebars.compile(template.toString()); + const contents = compiled({ name: basename(destination), ...context }); + + await fs.writeFile(destination, contents).catch(error => { + throw new Error( + `Failed to create file: ${destination}: ${error.message}`, + ); + }); + }); + } else { + await Task.forItem('copying', basename(file), async () => { + await fs.copyFile(file, destinationFile).catch(error => { + const destination = destinationFile; + throw new Error( + `Failed to copy file to ${destination} : ${error.message}`, + ); + }); + }); + } + } +} diff --git a/packages/create-app/src/lib/version.ts b/packages/create-app/src/lib/version.ts new file mode 100644 index 0000000000..24734b87cc --- /dev/null +++ b/packages/create-app/src/lib/version.ts @@ -0,0 +1,26 @@ +/* + * 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-extra'; +import { paths } from './paths'; + +export function findVersion() { + const pkgContent = fs.readFileSync(paths.resolveOwn('package.json'), 'utf8'); + return JSON.parse(pkgContent).version; +} + +export const version = findVersion(); +export const isDev = fs.pathExistsSync(paths.resolveOwn('src'));