Merge pull request #437 from spotify/rugvip/create-app
cli: add create-app command
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+16
@@ -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();
|
||||
});
|
||||
});
|
||||
+106
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user