refactor(cli): move create-app into his own package

This commit is contained in:
Rémi DOREAU
2020-07-18 17:02:09 +02:00
parent 75abb42217
commit a43d29eb9d
11 changed files with 507 additions and 15 deletions
-11
View File
@@ -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')
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
ignorePatterns: ['templates/**'],
rules: {
'no-console': 0,
},
};
+22
View File
@@ -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)
+34
View File
@@ -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');
}
+44
View File
@@ -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"
}
}
@@ -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<void> => {
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);
+69
View File
@@ -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<T extends readonly any[], Export extends string>(
actionRequireFunc: () => Promise<
{ [name in Export]: (...args: T) => Promise<any> }
>,
exportName: Export,
): (...args: T) => Promise<never> {
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);
+46
View File
@@ -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);
}
}
+150
View File
@@ -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();
+105
View File
@@ -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<void>,
): Promise<void> {
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}`,
);
});
});
}
}
}
+26
View File
@@ -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'));