Merge pull request #437 from spotify/rugvip/create-app

cli: add create-app command
This commit is contained in:
Patrik Oldsberg
2020-04-01 21:07:31 +02:00
committed by GitHub
40 changed files with 979 additions and 355 deletions
@@ -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 path from 'path';
import { promisify } from 'util';
import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import { realpathSync } from 'fs';
import os from 'os';
import { Task, templatingTask } from '../../helpers/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, name: string) {
await Task.forItem('checking', name, async () => {
const destination = path.join(rootDir, 'plugins', name);
if (await fs.pathExists(destination)) {
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
throw new Error(
`A directory with the same name already exists: ${existing}\nPlease try again with a different app name`,
);
}
});
}
export async function createTemporaryAppFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
} catch (error) {
throw new Error(
`Failed to create temporary app directory: ${error.message}`,
);
}
});
}
async function cleanUp(tempDir: string) {
await Task.forItem('remove', 'temporary directory', async () => {
await fs.remove(tempDir);
});
}
async function buildApp(pluginFolder: string) {
const commands = ['yarn install', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
await exec(command).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
});
});
}
}
export async function moveApp(
tempDir: string,
destination: string,
id: string,
) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
`Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`,
);
});
});
}
export default async () => {
const questions: Question[] = [
{
type: 'input',
name: 'name',
message: chalk.blue('Enter a name for the app [required]'),
validate: (value: any) => {
if (!value) {
return chalk.red('Please enter a name for the app');
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
return chalk.red(
'Plugin name must be kebab-cased and contain only letters, digits, and dashes.',
);
}
return true;
},
},
];
const answers: Answers = await inquirer.prompt(questions);
const rootDir = realpathSync(process.cwd());
const cliPackage = resolvePath(__dirname, '../../..');
const templateDir = resolvePath(cliPackage, 'templates', 'default-app');
const tempDir = path.join(os.tmpdir(), answers.name);
const appDir = path.join(rootDir, answers.name);
const version = require(resolvePath(cliPackage, 'package.json')).version;
Task.log();
Task.log('Creating the app...');
try {
Task.section('Checking if the directory is available');
await checkExists(rootDir, answers.name);
Task.section('Creating a temporary app directory');
await createTemporaryAppFolder(tempDir);
Task.section('Preparing files');
await templatingTask(templateDir, tempDir, { ...answers, version });
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
Task.section('Building the app');
await buildApp(appDir);
Task.log();
Task.log(
chalk.green(`🥇 Successfully created ${chalk.cyan(answers.name)}`),
);
Task.log();
} catch (error) {
Task.error(error.message);
Task.log('It seems that something went wrong when creating the app 🤔');
Task.log('We are going to clean up, and then you can try again.');
Task.section('Cleanup');
await cleanUp(tempDir);
Task.error('🔥 Failed to create app!');
}
};
@@ -14,115 +14,55 @@
* limitations under the License.
*/
import fs from 'fs';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import del from 'del';
import {
createFileFromTemplate,
createFromTemplateDir,
createTemporaryPluginFolder,
movePlugin,
} from './createPlugin';
import { createTemporaryPluginFolder, movePlugin } from './createPlugin';
describe('createPlugin', () => {
describe('createPluginFolder', () => {
it('should create a temporary plugin directory in the correct place', () => {
it('should create a temporary plugin directory in the correct place', async () => {
const id = 'testPlugin';
const tempDir = path.join(os.tmpdir(), id);
try {
createTemporaryPluginFolder(tempDir);
expect(fs.existsSync(tempDir)).toBe(true);
await createTemporaryPluginFolder(tempDir);
await expect(fs.pathExists(tempDir)).resolves.toBe(true);
expect(tempDir).toMatch(id);
} finally {
del.sync(tempDir, { force: true });
await del(tempDir, { force: true });
}
});
it('should not create a temporary plugin directory if it already exists', () => {
it('should not create a temporary plugin directory if it already exists', async () => {
const id = 'testPlugin';
const tempDir = path.join(os.tmpdir(), id);
try {
createTemporaryPluginFolder(tempDir);
expect(fs.existsSync(tempDir)).toBe(true);
expect(() => createTemporaryPluginFolder(tempDir)).toThrowError(
await createTemporaryPluginFolder(tempDir);
await expect(fs.pathExists(tempDir)).resolves.toBe(true);
await expect(createTemporaryPluginFolder(tempDir)).rejects.toThrow(
/Failed to create temporary plugin directory/,
);
} finally {
del.sync(tempDir, { force: true });
}
});
});
describe('createFileFromTemplate', () => {
it('should generate a valid output with inserted values', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
try {
const sourceData =
'{"name": "@backstage/{{id}}", "version": "{{version}}"}';
const targetData = '{"name": "@backstage/foo", "version": "0.0.0"}';
const sourcePath = path.join(tempDir, 'in.hbs');
const targetPath = path.join(tempDir, 'out.json');
fs.writeFileSync(sourcePath, sourceData);
createFileFromTemplate(sourcePath, targetPath, { id: 'foo' }, '0.0.0');
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath).toString()).toBe(targetData);
} finally {
del.sync(tempDir, { force: true });
}
});
});
describe('createFromTemplateDir', () => {
it('should create sub-directories and files', async () => {
const templateRootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
const templateSubDir = fs.mkdtempSync(path.join(templateRootDir, 'sub-'));
fs.writeFileSync(path.join(templateSubDir, 'test.txt'), 'testing');
const destinationRootDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'test-'),
);
const subDir = path.join(
destinationRootDir,
path.basename(templateSubDir),
);
const testFile = path.join(
destinationRootDir,
path.basename(templateSubDir),
'test.txt',
);
try {
await createFromTemplateDir(
templateRootDir,
destinationRootDir,
{},
'0.0.0',
);
expect(fs.existsSync(subDir)).toBe(true);
expect(fs.existsSync(testFile)).toBe(true);
} finally {
await del(templateRootDir, { force: true });
await del(destinationRootDir, { force: true });
await del(tempDir, { force: true });
}
});
});
describe('movePlugin', () => {
it('should move the temporary plugin directory to its final place', () => {
it('should move the temporary plugin directory to its final place', async () => {
const id = 'testPlugin';
const tempDir = path.join(os.tmpdir(), id);
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'));
const pluginDir = path.join(rootDir, 'plugins', id);
try {
createTemporaryPluginFolder(tempDir);
movePlugin(tempDir, pluginDir, id);
expect(fs.existsSync(pluginDir)).toBe(true);
await createTemporaryPluginFolder(tempDir);
await movePlugin(tempDir, pluginDir, id);
await expect(fs.pathExists(pluginDir)).resolves.toBe(true);
expect(pluginDir).toMatch(`/plugins\/${id}`);
} finally {
del.sync(tempDir, { force: true });
del.sync(rootDir, { force: true });
await del(tempDir, { force: true });
await del(rootDir, { force: true });
}
});
});
@@ -16,86 +16,44 @@
import fs from 'fs-extra';
import path from 'path';
import handlebars from 'handlebars';
import { promisify } from 'util';
import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import recursive from 'recursive-readdir';
import { execSync } from 'child_process';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import { realpathSync, existsSync } from 'fs';
import os from 'os';
import ora from 'ora';
import {
parseOwnerIds,
addCodeownersEntry,
getCodeownersFilePath,
} from './lib/codeowners';
import { Task, templatingTask } from '../../helpers/tasks';
const exec = promisify(execCb);
const MARKER_SUCCESS = chalk.green(` ✔︎\n`);
const MARKER_FAILURE = chalk.red(`\n`);
async function checkExists(rootDir: string, id: string) {
await Task.forItem('checking', id, async () => {
const destination = path.join(rootDir, 'plugins', id);
const checkExists = (rootDir: string, id: string) => {
console.log();
console.log(chalk.green(' Checking if the plugin already exists:'));
const destination = path.join(rootDir, 'plugins', id);
if (fs.existsSync(destination)) {
console.log(
chalk.red(
` plugin ID\t${chalk.cyan(id)} already exists${MARKER_FAILURE}`,
),
);
throw new Error(
`A plugin with the same name already exists: ${chalk.cyan(
destination.replace(`${rootDir}/`, ''),
)}\nPlease try again with a different plugin ID`,
);
}
console.log(
chalk.green(` plugin ID\t${chalk.cyan(id)} is available${MARKER_SUCCESS}`),
);
};
export const createTemporaryPluginFolder = (tempDir: string) => {
console.log();
console.log(chalk.green(' Creating a temporary plugin directory:'));
process.stdout.write(
chalk.green(` creating\t${chalk.cyan('temporary')} directory`),
);
try {
fs.mkdirSync(tempDir);
process.stdout.write(MARKER_SUCCESS);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Failed to create temporary plugin directory: ${e.message}`,
);
}
};
export const createFileFromTemplate = (
source: string,
destination: string,
answers: Answers,
version: string,
) => {
const template = fs.readFileSync(source);
const compiled = handlebars.compile(template.toString());
const contents = compiled({
name: path.basename(destination),
version,
...answers,
if (await fs.pathExists(destination)) {
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
throw new Error(
`A plugin with the same name already exists: ${existing}\nPlease try again with a different plugin ID`,
);
}
});
try {
fs.writeFileSync(destination, contents);
process.stdout.write(MARKER_SUCCESS);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(`Failed to create file: ${destination}: ${e.message}`);
}
};
}
export async function createTemporaryPluginFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
} catch (error) {
throw new Error(
`Failed to create temporary plugin directory: ${error.message}`,
);
}
});
}
const sortObjectByKeys = (obj: { [name in string]: string }) => {
return Object.keys(obj)
@@ -109,9 +67,9 @@ const sortObjectByKeys = (obj: { [name in string]: string }) => {
const capitalize = (str: string): string =>
str.charAt(0).toUpperCase() + str.slice(1);
const addExportStatement = (file: string, exportStatement: string) => {
const newContents = fs
.readFileSync(file, 'utf8')
async function addExportStatement(file: string, exportStatement: string) {
const contents = await fs.readFile(file, 'utf8');
const newContents = contents
.split('\n')
.filter(Boolean) // get rid of empty lines
.concat([exportStatement])
@@ -119,28 +77,20 @@ const addExportStatement = (file: string, exportStatement: string) => {
.concat(['']) // newline at end of file
.join('\n');
fs.writeFileSync(file, newContents, 'utf8');
};
await fs.writeFile(file, newContents, 'utf8');
}
export const addPluginDependencyToApp = (
export async function addPluginDependencyToApp(
rootDir: string,
pluginName: string,
version: string,
) => {
console.log();
console.log(chalk.green(' Adding plugin as dependency in app:'));
) {
const pluginPackage = `@backstage/plugin-${pluginName}`;
const packageFile = path.join(rootDir, 'packages', 'app', 'package.json');
const packageFilePath = 'packages/app/package.json';
const packageFile = path.join(rootDir, packageFilePath);
process.stdout.write(
chalk.green(
` processing\t${chalk.cyan(packageFile.replace(`${rootDir}/`, ''))}`,
),
);
try {
const packageFileContent = fs.readFileSync(packageFile, 'utf-8');
await Task.forItem('processing', packageFilePath, async () => {
const packageFileContent = await fs.readFile(packageFile, 'utf-8');
const packageFileJson = JSON.parse(packageFileContent);
const dependencies = packageFileJson.dependencies;
@@ -152,187 +102,72 @@ export const addPluginDependencyToApp = (
dependencies[pluginPackage] = `^${version}`;
packageFileJson.dependencies = sortObjectByKeys(dependencies);
fs.writeFileSync(
packageFile,
`${JSON.stringify(packageFileJson, null, 2)}\n`,
'utf-8',
);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Failed to add plugin as dependency in app: ${packageFile}: ${e.message}`,
);
}
const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`;
process.stdout.write(MARKER_SUCCESS);
};
export const addPluginToApp = (rootDir: string, pluginName: string) => {
console.log();
console.log(chalk.green(' Import plugin in app:'));
await fs.writeFile(packageFile, newContents, 'utf-8').catch(error => {
throw new Error(
`Failed to add plugin as dependency to app: ${packageFile}: ${error.message}`,
);
});
});
}
export async function addPluginToApp(rootDir: string, pluginName: string) {
const pluginPackage = `@backstage/plugin-${pluginName}`;
const pluginNameCapitalized = pluginName
.split('-')
.map(name => capitalize(name))
.join('');
const pluginExport = `export { default as ${pluginNameCapitalized} } from '${pluginPackage}';`;
const pluginsFile = path.join(
rootDir,
'packages',
'app',
'src',
'plugins.ts',
);
process.stdout.write(
chalk.green(
` processing\t${chalk.cyan(pluginsFile.replace(`${rootDir}/`, ''))}`,
),
);
const pluginsFilePath = 'packages/app/src/plugins.ts';
const pluginsFile = resolvePath(rootDir, pluginsFilePath);
try {
addExportStatement(pluginsFile, pluginExport);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Failed to import plugin in app: ${pluginsFile}: ${e.message}`,
);
}
process.stdout.write(MARKER_SUCCESS);
};
export const createFromTemplateDir = async (
templateFolder: string,
destinationFolder: string,
answers: Answers,
version: string,
) => {
console.log();
console.log(chalk.green(' Reading template files:'));
let files = [];
try {
files = await recursive(templateFolder);
console.log(
chalk.green(
` reading\t${chalk.cyan(`${files.length} files`)}${MARKER_SUCCESS}`,
),
);
} catch (e) {
console.log(
chalk.red(` reading\t${chalk.cyan('0')} files${MARKER_FAILURE}`),
);
throw new Error(`Failed to read files in template directory: ${e.message}`);
}
console.log();
console.log(chalk.green(' Setting up the plugin files:'));
files.forEach(file => {
process.stdout.write(
chalk.green(` processing\t${chalk.cyan(path.basename(file))}`),
);
fs.ensureDirSync(
file
.replace(templateFolder, destinationFolder)
.replace(path.basename(file), ''),
);
if (file.endsWith('hbs')) {
createFileFromTemplate(
file,
file.replace(templateFolder, destinationFolder).replace(/\.hbs$/, ''),
answers,
version,
await Task.forItem('processing', pluginsFilePath, async () => {
await addExportStatement(pluginsFile, pluginExport).catch(error => {
throw new Error(
`Failed to import plugin in app: ${pluginsFile}: ${error.message}`,
);
} else {
try {
fs.copyFileSync(file, file.replace(templateFolder, destinationFolder));
process.stdout.write(MARKER_SUCCESS);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Failed to copy file: ${file.replace(
templateFolder,
destinationFolder,
)}: ${e.message}`,
);
}
}
});
});
};
}
const cleanUp = async (tempDir: string, id: string) => {
console.log(
chalk.green(
`It seems that something went wrong when creating the plugin 🤔 `,
),
);
console.log(
chalk.green('We are going to clean up, and then you can try again.'),
);
const spinner = ora({
prefixText: chalk.green(` Cleaning up\t${chalk.cyan(id)}`),
spinner: 'arc',
color: 'green',
}).start();
try {
async function cleanUp(tempDir: string) {
await Task.forItem('remove', 'temporary directory', async () => {
await fs.remove(tempDir);
spinner.succeed();
} catch (e) {
spinner.fail();
console.log(chalk.red(`Failed to cleanup: ${e.message}`));
}
};
const buildPlugin = async (pluginFolder: string) => {
console.log();
console.log(chalk.green(` Building the plugin:`));
});
}
async function buildPlugin(pluginFolder: string) {
const commands = ['yarn install', 'yarn build'];
for (const command of commands) {
const spinner = ora({
prefixText: chalk.green(` executing\t${chalk.cyan(command)}`),
spinner: 'arc',
color: 'green',
}).start();
try {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
execSync(command, { timeout: 60000, stdio: 'pipe' });
spinner.succeed();
} catch (e) {
spinner.fail();
process.stdout.write(e.stderr);
process.stdout.write(e.stdout);
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
}
}
};
export const movePlugin = (
await exec(command).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
});
});
}
}
export async function movePlugin(
tempDir: string,
destination: string,
id: string,
) => {
console.log();
console.log(chalk.green(` Moving the plugin:`));
) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
`Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`,
);
});
});
}
process.stdout.write(
chalk.green(` moving\t${chalk.cyan(id)} to final location`),
);
try {
fs.moveSync(tempDir, destination);
process.stdout.write(MARKER_SUCCESS);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Failed to move plugin from ${tempDir} to ${destination}: ${e.message}`,
);
}
};
const createPlugin = async () => {
const rootDir = realpathSync(process.cwd());
export default async () => {
const rootDir = await fs.realpath(process.cwd());
const codeownersPath = await getCodeownersFilePath(rootDir);
const questions: Question[] = [
@@ -381,25 +216,37 @@ const createPlugin = async () => {
const appPackage = resolvePath(rootDir, 'packages', 'app');
const cliPackage = resolvePath(__dirname, '..', '..', '..');
const templateFolder = resolvePath(cliPackage, 'templates', 'default-plugin');
const templateDir = resolvePath(cliPackage, 'templates', 'default-plugin');
const tempDir = path.join(os.tmpdir(), answers.id);
const pluginDir = path.join(rootDir, 'plugins', answers.id);
const version = require(resolvePath(cliPackage, 'package.json')).version;
const ownerIds = parseOwnerIds(answers.owner);
console.log();
console.log(chalk.green('Creating the plugin...'));
Task.log();
Task.log('Creating the plugin...');
try {
checkExists(rootDir, answers.id);
createTemporaryPluginFolder(tempDir);
await createFromTemplateDir(templateFolder, tempDir, answers, version);
movePlugin(tempDir, pluginDir, answers.id);
Task.section('Checking if the plugin ID is available');
await checkExists(rootDir, answers.id);
Task.section('Creating a temporary plugin directory');
await createTemporaryPluginFolder(tempDir);
Task.section('Preparing files');
await templatingTask(templateDir, tempDir, { ...answers, version });
Task.section('Moving to final location');
await movePlugin(tempDir, pluginDir, answers.id);
Task.section('Building the plugin');
await buildPlugin(pluginDir);
if (existsSync(appPackage)) {
addPluginDependencyToApp(rootDir, answers.id, version);
addPluginToApp(rootDir, answers.id);
if (await fs.pathExists(appPackage)) {
Task.section('Adding plugin as dependency in app');
await addPluginDependencyToApp(rootDir, answers.id, version);
Task.section('Import plugin in app');
await addPluginToApp(rootDir, answers.id);
}
if (ownerIds && ownerIds.length) {
@@ -410,24 +257,21 @@ const createPlugin = async () => {
);
}
console.log();
console.log(
chalk.green(
`🥇 Successfully created ${chalk.cyan(
`@backstage/plugin-${answers.id}`,
)}`,
),
Task.log();
Task.log(
`🥇 Successfully created ${chalk.cyan(
`@backstage/plugin-${answers.id}`,
)}`,
);
console.log();
} catch (e) {
console.log();
console.log(`${chalk.red(e.message)}`);
console.log();
await cleanUp(tempDir, answers.id);
console.log();
console.log(`🔥 ${chalk.red('Failed to create plugin!')}`);
console.log();
Task.log();
} catch (error) {
Task.error(error.message);
Task.log('It seems that something went wrong when creating the plugin 🤔');
Task.log('We are going to clean up, and then you can try again.');
Task.section('Cleanup');
await cleanUp(tempDir);
Task.error('🔥 Failed to create plugin!');
}
};
export default createPlugin;
+2 -2
View File
@@ -37,10 +37,10 @@ export class ExitCodeError extends CustomError {
export function exitWithError(error: Error): never {
if (error instanceof ExitCodeError) {
process.stderr.write(`${chalk.red(error.message)}\n`);
process.stderr.write(`\n${chalk.red(error.message)}\n\n`);
process.exit(error.code);
} else {
process.stderr.write(`${chalk.red(`${error}`)}\n`);
process.stderr.write(`\n${chalk.red(`${error}`)}\n\n`);
process.exit(1);
}
}
+53
View File
@@ -0,0 +1,53 @@
/*
* 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 { resolve as resolvePath } from 'path';
import os from 'os';
import del from 'del';
import { templatingTask } from './tasks';
describe('templatingTask', () => {
it('should template a directory with mix of regular files and templates', async () => {
// Set up a testing template directory
const tmplDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
await fs.ensureDir(resolvePath(tmplDir, 'sub'));
await fs.writeFile(resolvePath(tmplDir, 'test.txt'), 'testing');
await fs.writeFile(
resolvePath(tmplDir, 'sub/version.txt.hbs'),
'version: {{version}}',
);
// Set up a temporary dest dir to write the template to
const destDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-'));
try {
await templatingTask(tmplDir, destDir, {
version: '0.0.0',
});
await expect(
fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'),
).resolves.toBe('testing');
await expect(
fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'),
).resolves.toBe('version: 0.0.0');
} finally {
await del(tmplDir, { force: true });
await del(destDir, { force: true });
}
});
});
+101
View File
@@ -0,0 +1,101 @@
/*
* 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 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}`,
);
});
});
}
}
}
+10 -1
View File
@@ -17,6 +17,8 @@
import program from 'commander';
import chalk from 'chalk';
import fs from 'fs';
import { resolve as resolvePath } from 'path';
import createAppCommand from './commands/create-app/createApp';
import createPluginCommand from './commands/create-plugin/createPlugin';
import watch from './commands/watch-deps';
import buildCache from './commands/build-cache';
@@ -29,10 +31,17 @@ import pluginServe from './commands/plugin/serve';
import { exitWithError } from './helpers/errors';
const main = (argv: string[]) => {
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
const packageJson = JSON.parse(
fs.readFileSync(resolvePath(__dirname, '../package.json'), 'utf-8'),
);
program.name('backstage-cli').version(packageJson.version ?? '0.0.0');
program
.command('create-app')
.description('Creates a new app in a new directory')
.action(actionHandler(createAppCommand));
program
.command('app:build')
.description('Build an app for a production release')
@@ -0,0 +1,3 @@
# [Backstage](https://backstage.io)
This is your newly scaffolded Backstage App, Good Luck!
@@ -0,0 +1,6 @@
{
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.0"
}
@@ -0,0 +1,24 @@
{
"name": "root",
"private": true,
"scripts": {
"start": "yarn build && yarn workspace app start",
"build": "lerna run build",
"test": "cross-env CI=true lerna run test -- --coverage",
"create-plugin": "backstage-cli create-plugin",
"lint": "lerna run lint"
},
"workspaces": {
"packages": [
"packages/*",
"plugins/*"
]
},
"version": "1.0.0",
"devDependencies": {
"@backstage/cli": "^{{version}}",
"cross-env": "^7.0.0",
"lerna": "^3.20.2",
"prettier": "^1.19.1"
}
}
@@ -0,0 +1,39 @@
{
"name": "app",
"version": "0.0.0",
"private": true,
"dependencies": {
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@backstage/cli": "^{{version}}",
"@backstage/core": "^{{version}}",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
"plugin-welcome": "0.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2"
},
"scripts": {
"start": "backstage-cli app:serve",
"build": "backstage-cli app:build",
"test": "backstage-cli test",
"lint": "backstage-cli lint"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Backstage is an open platform for building developer portals"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link
rel="manifest"
href="%PUBLIC_URL%/manifest.json"
crossorigin="use-credentials"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link
rel="mask-icon"
href="%PUBLIC_URL%/safari-pinned-tab.svg"
color="#5bbad5"
/>
<style>
@media only screen {
html {
zoom: 0.7;
}
}
@media only screen and (min-width: 800px) {
html {
zoom: 0.85;
}
}
@media only screen and (min-width: 1200px) {
html {
zoom: 1;
}
}
#root {
min-height: 100%;
}
</style>
<title>Backstage</title>
</head>
<body style="margin: 0">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
@@ -0,0 +1,15 @@
{
"short_name": "Backstage",
"name": "Backstage",
"icons": [
{
"src": "favicon.ico",
"sizes": "48x48",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
@@ -0,0 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
@@ -0,0 +1,28 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M492 4610 c-4 -3 -8 -882 -7 -1953 l0 -1948 850 2 c898 1 945 3 1118
49 505 134 823 531 829 1037 2 136 -9 212 -44 323 -40 125 -89 218 -163 310
-35 43 -126 128 -169 157 -22 15 -43 30 -46 33 -12 13 -131 70 -188 91 l-64
22 60 28 c171 77 317 224 403 404 64 136 92 266 91 425 -5 424 -245 770 -642
923 -79 30 -105 39 -155 50 -11 3 -38 10 -60 15 -22 6 -60 13 -85 17 -25 3
-58 9 -75 12 -36 8 -1643 11 -1653 3z m1497 -743 c236 -68 352 -254 305 -486
-26 -124 -110 -224 -232 -277 -92 -40 -151 -46 -439 -49 l-283 -3 -1 27 c-1
36 -1 760 0 790 l1 23 298 -5 c226 -4 310 -9 351 -20z m-82 -1538 c98 -3 174
-19 247 -52 169 -78 257 -212 258 -395 0 -116 -36 -221 -100 -293 -64 -72
-192 -135 -314 -155 -23 -3 -181 -7 -350 -8 l-308 -2 -1 26 c-6 210 1 874 9
879 9 5 366 6 559 0z"/>
<path d="M4160 1789 c-275 -24 -499 -263 -503 -536 -1 -115 21 -212 66 -292
210 -369 697 -402 950 -65 77 103 110 199 111 329 0 50 -6 113 -13 140 -16 58
-62 155 -91 193 -33 43 -122 132 -132 132 -5 0 -26 11 -46 25 -85 56 -219 85
-342 74z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
describe('App', () => {
it('should render', () => {
const rendered = render(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
import { BackstageTheme, createApp } from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import * as plugins from './plugins';
const useStyles = makeStyles(theme => ({
'@global': {
html: {
height: '100%',
fontFamily: theme.typography.fontFamily,
},
body: {
height: '100%',
fontFamily: theme.typography.fontFamily,
'overscroll-behavior-y': 'none',
},
a: {
color: 'inherit',
textDecoration: 'none',
},
},
}));
const app = createApp();
app.registerPlugin(...Object.values(plugins));
const AppComponent = app.build();
const App: FC<{}> = () => {
useStyles();
return (
<CssBaseline>
<ThemeProvider theme={BackstageTheme}>
<Router>
<AppComponent />
</Router>
</ThemeProvider>
</CssBaseline>
);
};
export default App;
@@ -0,0 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
@@ -0,0 +1 @@
export { default as WelcomePlugin } from 'plugin-welcome';
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,20 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"incremental": false
},
"include": ["src"]
}
@@ -0,0 +1,3 @@
# Title
Welcome to the welcome plugin!
@@ -0,0 +1,20 @@
{
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/cjs/index.js",
"types": "dist/cjs/index.d.ts",
"private": true,
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test"
},
"devDependencies": {
"@backstage/core": "^{{version}}",
"@backstage/cli": "^{{version}}",
"@types/testing-library__jest-dom": "5.0.2"
},
"dependencies": {
"@material-ui/lab": "4.0.0-alpha.45"
}
}
@@ -0,0 +1,58 @@
import React, { FC } from 'react';
import { HeaderLabel } from '@backstage/core';
const timeFormat = { hour: '2-digit', minute: '2-digit' };
const utcOptions = { timeZone: 'UTC', ...timeFormat };
const nycOptions = { timeZone: 'America/New_York', ...timeFormat };
const tyoOptions = { timeZone: 'Asia/Tokyo', ...timeFormat };
const stoOptions = { timeZone: 'Europe/Stockholm', ...timeFormat };
const defaultTimes = {
timeNY: '',
timeUTC: '',
timeTYO: '',
timeSTO: '',
};
function getTimes() {
const d = new Date();
const lang = window.navigator.language;
// Using the browser native toLocaleTimeString instead of huge moment-tz
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString
const timeNY = d.toLocaleTimeString(lang, nycOptions);
const timeUTC = d.toLocaleTimeString(lang, utcOptions);
const timeTYO = d.toLocaleTimeString(lang, tyoOptions);
const timeSTO = d.toLocaleTimeString(lang, stoOptions);
return { timeNY, timeUTC, timeTYO, timeSTO };
}
const HomePageTimer: FC<{}> = () => {
const [{ timeNY, timeUTC, timeTYO, timeSTO }, setTimes] = React.useState(
defaultTimes,
);
React.useEffect(() => {
setTimes(getTimes());
const intervalId = setInterval(() => {
setTimes(getTimes());
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
return (
<>
<HeaderLabel label="NYC" value={timeNY} />
<HeaderLabel label="UTC" value={timeUTC} />
<HeaderLabel label="STO" value={timeSTO} />
<HeaderLabel label="TYO" value={timeTYO} />
</>
);
};
export default HomePageTimer;
@@ -0,0 +1 @@
export { default } from './Timer';
@@ -0,0 +1,16 @@
import React from 'react';
import { render } from '@testing-library/react';
import WelcomePage from './WelcomePage';
import { ThemeProvider } from '@material-ui/core';
import { BackstageTheme } from '@backstage/core';
describe('WelcomePage', () => {
it('should render', () => {
const rendered = render(
<ThemeProvider theme={BackstageTheme}>
<WelcomePage />
</ThemeProvider>,
);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -0,0 +1,106 @@
import React, { FC } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Typography,
Grid,
List,
ListItem,
ListItemText,
Link,
} from '@material-ui/core';
import Timer from '../Timer';
import {
Content,
InfoCard,
Header,
Page,
pageTheme,
ContentHeader,
SupportButton,
} from '@backstage/core';
const WelcomePage: FC<{}> = () => {
const profile = { givenName: '' };
return (
<Page theme={pageTheme.home}>
<Header
title={`Welcome ${profile.givenName || 'to Backstage'}`}
subtitle="Some quick intro and links."
>
<Timer />
</Header>
<Content>
<ContentHeader title="Getting Started">
<SupportButton />
</ContentHeader>
<Grid container>
<Grid item xs={12} md={6}>
<InfoCard maxWidth>
<Typography variant="body1" gutterBottom>
You now have a running instance of Backstage!
<span role="img" aria-label="confetti">
🎉
</span>
Let's make sure you get the most out of this platform by walking
you through the basics.
</Typography>
<Typography variant="h6" gutterBottom>
The Setup
</Typography>
<Typography variant="body1" paragraph>
Backstage is put together from three base concepts: the core,
the app and the plugins.
</Typography>
<List>
<ListItem>
<ListItemText primary="The core is responsible for base functionality." />
</ListItem>
<ListItem>
<ListItemText primary="The app provides the base UI and connects the plugins." />
</ListItem>
<ListItem>
<ListItemText
primary="The plugins make Backstage useful for the end users with
specific views and functionality."
/>
</ListItem>
</List>
<Typography variant="h6" gutterBottom>
Try It Out
</Typography>
<Typography variant="body1" paragraph>
We suggest you either check out the documentation for{' '}
<Link href="https://github.com/spotify/backstage/blob/master/docs/getting-started/create-a-plugin.md">
creating a plugin
</Link>{' '}
or have a look in the code for the{' '}
<Link component={RouterLink} to="/home">
Home Page
</Link>{' '}
in the directory "plugins/home-page/src".
</Typography>
</InfoCard>
</Grid>
<Grid item>
<InfoCard>
<Typography variant="h5">Quick Links</Typography>
<List>
<ListItem>
<Link href="https://backstage.io">backstage.io</Link>
</ListItem>
<ListItem>
<Link href="https://github.com/spotify/backstage/blob/master/docs/getting-started/create-a-plugin.md">
Create a plugin
</Link>
</ListItem>
</List>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
export default WelcomePage;
@@ -0,0 +1 @@
export { default } from './WelcomePage';
@@ -0,0 +1 @@
export { default } from './plugin';
@@ -0,0 +1,7 @@
import plugin from './plugin';
describe('welcome', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
@@ -0,0 +1,9 @@
import { createPlugin } from '@backstage/core';
import WelcomePage from './components/WelcomePage';
export default createPlugin({
id: 'welcome',
register({ router }) {
router.registerRoute('/', WelcomePage);
},
});
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
@@ -0,0 +1 @@
module.exports = require('@spotify/web-scripts/config/prettier.config.js');
@@ -0,0 +1,10 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"exclude": ["**/*.test.*"],
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"declarationMap": true,
"incremental": true
}
}