frontend: move to repo root

This commit is contained in:
Patrik Oldsberg
2020-03-05 15:55:28 +01:00
parent 5b9845f62f
commit 56226ae84d
184 changed files with 111 additions and 2097 deletions
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../cjs');
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@spotify-backstage/cli",
"version": "1.3.0",
"main": "src/index.ts",
"main:src": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"scripts": {
"exec": "npx ts-node ./src",
"build": "web-scripts build",
"lint": "web-scripts lint",
"test": "web-scripts test",
"start": "nodemon ."
},
"devDependencies": {
"@spotify/web-scripts": "^6.0.0",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
"@types/node": "^13.7.2",
"@types/react-dev-utils": "^9.0.4",
"@types/recursive-readdir": "^2.2.0",
"@types/webpack": "^4.41.7",
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"ts-node": "^8.6.2"
},
"bin": {
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"chokidar": "^3.3.1",
"commander": "^4.1.1",
"dashify": "^2.0.0",
"fork-ts-checker-webpack-plugin": "^4.0.5",
"fs-extra": "^8.1.0",
"handlebars": "^4.7.3",
"html-webpack-plugin": "^3.2.0",
"inquirer": "^7.0.4",
"react-dev-utils": "^10.2.0",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^5.0.2",
"ts-loader": "^6.2.1",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3"
},
"files": [
"templates",
"bin",
"cjs"
],
"nodemonConfig": {
"watch": "./src",
"exec": "ts-node",
"ext": "ts"
}
}
@@ -0,0 +1,86 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import del from 'del';
import {
createFileFromTemplate,
createFromTemplateDir,
createPluginFolder,
} from './createPlugin';
describe('createPlugin', () => {
describe('createPluginFolder', () => {
it('should create a plugin directory in the correct place', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
try {
const pluginFolder = createPluginFolder(tempDir, 'foo');
expect(fs.existsSync(pluginFolder)).toBe(true);
expect(pluginFolder).toMatch(/packages\/plugins\/foo/);
} finally {
del.sync(tempDir, { force: true });
}
});
it('should not create a plugin directory if it already exists', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
try {
const pluginFolder = createPluginFolder(tempDir, 'foo');
expect(fs.existsSync(pluginFolder)).toBe(true);
expect(() => createPluginFolder(tempDir, 'foo')).toThrowError(
/A plugin with the same name already exists/,
);
} 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": "@spotify-backstage/{{id}}"}';
const targetData = '{"name": "@spotify-backstage/foo"}';
const sourcePath = path.join(tempDir, 'in.hbs');
const targetPath = path.join(tempDir, 'out.json');
fs.writeFileSync(sourcePath, sourceData);
createFileFromTemplate(sourcePath, targetPath, { id: 'foo' });
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, {});
expect(fs.existsSync(subDir)).toBe(true);
expect(fs.existsSync(testFile)).toBe(true);
} finally {
await del(templateRootDir, { force: true });
await del(destinationRootDir, { force: true });
}
});
});
});
+344
View File
@@ -0,0 +1,344 @@
import fs from 'fs-extra';
import path from 'path';
import handlebars from 'handlebars';
import chalk from 'chalk';
import inquirer, { Answers, Question } from 'inquirer';
import recursive from 'recursive-readdir';
import { promisify } from 'util';
import { exec } from 'child_process';
import { resolve as resolvePath } from 'path';
import { realpathSync, existsSync } from 'fs';
const MARKER_SUCCESS = chalk.green(`\n`);
const MARKER_FAILURE = chalk.red(`\n`);
export const createPluginFolder = (rootDir: string, id: string): string => {
console.log();
console.log(chalk.green(' Creating the plugin directory:'));
const destination = path.join(rootDir, 'packages', 'plugins', id);
if (fs.existsSync(destination)) {
console.log(
chalk.red(
` failed:\t ✗ ${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
),
);
throw new Error(
`A plugin with the same name already exists: ${chalk.cyan(
destination.replace(`${rootDir}/`, ''),
)}\nPlease try again with a different Plugin ID`,
);
}
process.stdout.write(
chalk.green(
` creating\t${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
),
);
try {
fs.mkdirSync(destination, { recursive: true });
process.stdout.write(chalk.green(' ✓\n'));
return destination;
} catch (e) {
process.stdout.write(chalk.red(`\n`));
throw new Error(
`Failed to create plugin directory: ${destination}: ${e.message}`,
);
}
};
export const createFileFromTemplate = (
source: string,
destination: string,
answers: Answers,
) => {
const template = fs.readFileSync(source);
const compiled = handlebars.compile(template.toString());
const contents = compiled({
name: path.basename(destination),
...answers,
});
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}`);
}
};
const sortObjectByKeys = (obj: { [name in string]: string }) => {
return Object.keys(obj)
.sort()
.reduce((result, key: string) => {
result[key] = obj[key];
return result;
}, {} as { [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')
.split('\n')
.filter(Boolean) // get rid of empty lines
.concat([exportStatement])
.sort()
.concat(['']) // newline at end of file
.join('\n');
fs.writeFileSync(file, newContents, 'utf8');
};
export const addPluginDependencyToApp = (
rootDir: string,
pluginName: string,
) => {
console.log();
console.log(chalk.green(' Adding plugin as dependency in app:'));
const pluginPackage = `@spotify-backstage/plugin-${pluginName}`;
const pluginPackageVersion = '0.0.0';
const packageFile = path.join(rootDir, 'packages', 'app', 'package.json');
process.stdout.write(
chalk.green(
` processing\t${chalk.cyan(packageFile.replace(`${rootDir}/`, ''))}`,
),
);
try {
const packageFileContent = fs.readFileSync(packageFile, 'utf-8');
const packageFileJson = JSON.parse(packageFileContent);
const dependencies = packageFileJson.dependencies;
if (dependencies[pluginPackage]) {
throw new Error(
`Plugin ${pluginPackage} already exists in ${packageFile}`,
);
}
dependencies[pluginPackage] = pluginPackageVersion;
packageFileJson.dependencies = sortObjectByKeys(dependencies);
fs.writeFileSync(
packageFile,
`${JSON.stringify(packageFileJson, null, 2)}\n`,
'utf-8',
);
} catch (e) {
process.stdout.write(chalk.red(`\n`));
throw new Error(
`Failed to add plugin as dependency in app: ${packageFile}: ${e.message}`,
);
}
process.stdout.write(MARKER_SUCCESS);
};
export const addPluginToApp = (rootDir: string, pluginName: string) => {
console.log();
console.log(chalk.green(' Import plugin in app:'));
const pluginPackage = `@spotify-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}/`, ''))}`,
),
);
try {
addExportStatement(pluginsFile, pluginExport);
} catch (e) {
process.stdout.write(chalk.red(`\n`));
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,
) => {
console.log();
console.log(chalk.green(' Reading template files:'));
let files = [];
process.stdout.write(chalk.green(` reading\t`));
try {
files = await recursive(templateFolder);
process.stdout.write(
chalk.green(`${chalk.cyan(`${files.length} files`)}\n`),
);
} catch (e) {
console.log(chalk.red(` ✗ 0 files\n`));
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,
);
} 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 (rootDir: string, id: string) => {
const destination = path.join(rootDir, 'packages', 'plugins', id);
const questions: Question[] = [
{
type: 'confirm',
name: 'cleanup',
message: chalk.yellow(
`It seems that something went wrong when creating the plugin 🤔\nDo you want to remove the following directory and all the files in it:\n${chalk.cyan(
destination,
)}`,
),
},
];
const answers: Answers = await inquirer.prompt(questions);
if (answers.cleanup) {
console.log();
console.log(chalk.green(`🧹 Cleaning up...`));
console.log();
console.log(chalk.green(` Removing plugin:`));
process.stdout.write(
chalk.green(
` deleting\t${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
),
);
try {
// Not using recursion here, so only empty directories can be removed
fs.rmdirSync(destination);
process.stdout.write(MARKER_SUCCESS);
console.log();
} catch (e) {
process.stdout.write(MARKER_FAILURE);
console.log();
console.log(chalk.red(`Failed to cleanup: ${e.message}`));
}
}
};
const buildPlugin = async (pluginFolder: string) => {
console.log();
console.log(chalk.green(` Building the plugin:`));
const prom_exec = promisify(exec);
const commands = ['yarn install', 'yarn build'];
for (const command of commands) {
process.stdout.write(chalk.green(` executing\t${chalk.cyan(command)}`));
try {
process.chdir(pluginFolder);
await prom_exec(command, { timeout: 60000 });
process.stdout.write(MARKER_SUCCESS);
} catch (e) {
process.stdout.write(MARKER_FAILURE);
throw new Error(
`Could not execute command ${chalk.cyan(command)}: ${e.message}`,
);
}
}
};
const createPlugin = async (): Promise<any> => {
const questions: Question[] = [
{
type: 'input',
name: 'id',
message: chalk.blue('Enter an ID for the plugin [required]'),
validate: (value: any) =>
value ? true : chalk.red('Please enter an ID for the plugin'),
},
];
const answers: Answers = await inquirer.prompt(questions);
const rootDir = realpathSync(process.cwd());
const appPackage = resolvePath(rootDir, 'packages', 'app');
const cliPackage = resolvePath(__dirname, '..', '..');
const templateFolder = resolvePath(cliPackage, 'templates', 'default-plugin');
try {
console.log();
console.log(chalk.green('🧩 Creating the plugin...'));
const destinationFolder = createPluginFolder(rootDir, answers.id);
await createFromTemplateDir(templateFolder, destinationFolder, answers);
await buildPlugin(destinationFolder);
if (existsSync(appPackage)) {
addPluginDependencyToApp(rootDir, answers.id);
addPluginToApp(rootDir, answers.id);
}
console.log();
console.log(
chalk.green(
`🥇 Successfully created ${chalk.cyan(
`@spotify-backstage/plugin-${answers.id}`,
)}`,
),
);
console.log();
return destinationFolder;
} catch (e) {
console.log();
console.log(`${chalk.red(e.message)}`);
console.log();
console.log(`🔥 ${chalk.red('Failed to create plugin!')}`);
console.log();
await cleanUp(rootDir, answers.id);
}
};
export default createPlugin;
+111
View File
@@ -0,0 +1,111 @@
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import { Paths } from './paths';
// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles';
// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin';
// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
export function createConfig(paths: Paths): webpack.Configuration {
return {
mode: 'development',
profile: false,
bail: false,
devtool: 'cheap-module-eval-source-map',
context: paths.appPath,
entry: [
`${require.resolve('webpack-dev-server/client')}?/`,
require.resolve('webpack/hot/dev-server'),
paths.appDevEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
plugins: [
new ModuleScopePlugin(
[paths.appSrc, paths.appDev],
[paths.appPackageJson],
),
],
},
module: {
rules: [
{
test: /\.(tsx?|jsx?|mjs)$/,
enforce: 'pre',
include: [paths.appSrc, paths.appDev],
use: {
loader: 'eslint-loader',
options: {
emitWarning: true,
},
},
},
{
test: /\.(tsx?|jsx?|mjs)$/,
include: [paths.appSrc, paths.appDev],
exclude: /node_modules/,
loader: 'ts-loader',
options: {
// disable type checker - handled by ForkTsCheckerWebpackPlugin
transpileOnly: true,
},
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
loader: 'url-loader',
include: paths.appAssets,
options: {
limit: 10000,
name: 'static/media/[name].[hash:8].[ext]',
},
},
{
test: /\.ya?ml$/,
use: 'yml-loader',
},
{
include: /\.(md)$/,
use: 'raw-loader',
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
output: {
publicPath: '/',
filename: 'bundle.js',
},
plugins: [
new HtmlWebpackPlugin({
template: paths.appHtml,
}),
new ForkTsCheckerWebpackPlugin({
tsconfig: paths.appTsConfig,
eslint: true,
eslintOptions: {
parserOptions: {
project: paths.appTsConfig,
tsconfigRootDir: paths.appPath,
},
},
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
new webpack.HotModuleReplacementPlugin(),
],
node: {
module: 'empty',
dgram: 'empty',
dns: 'mock',
fs: 'empty',
http2: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
},
};
}
+11
View File
@@ -0,0 +1,11 @@
import chalk from 'chalk';
import { startDevServer } from './server';
export default async () => {
try {
await startDevServer();
} catch (error) {
process.stderr.write(`${chalk.red(error.message)}\n`);
process.exit(1);
}
};
+37
View File
@@ -0,0 +1,37 @@
import { resolve as resolvePath } from 'path';
import { existsSync, realpathSync } from 'fs';
export function getPaths() {
const appDir = realpathSync(process.cwd());
const resolveApp = (path: string) => resolvePath(appDir, path);
const resolveOwn = (path: string) => resolvePath(__dirname, '..', path);
const resolveAppModule = (path: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = resolveApp(`${path}.${ext}`);
if (existsSync(filePath)) {
return filePath;
}
}
return resolveApp(`${path}.js`);
};
let appHtml = resolveApp('dev/index.html');
if (!existsSync(appHtml)) {
appHtml = resolveOwn('../../templates/serve_index.html');
}
return {
appHtml,
appPath: resolveApp('.'),
appAssets: resolveApp('assets'),
appSrc: resolveApp('src'),
appDev: resolveApp('dev'),
appDevEntry: resolveAppModule('dev/index'),
appTsConfig: resolveApp('tsconfig.json'),
appNodeModules: resolveApp('node_modules'),
appPackageJson: resolveApp('package.json'),
};
}
export type Paths = ReturnType<typeof getPaths>;
+43
View File
@@ -0,0 +1,43 @@
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
import { getPaths } from './paths';
import { createConfig } from './config';
export async function startDevServer() {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const port = await choosePort(host, defaultPort);
if (!port) {
return;
}
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const urls = prepareUrls(protocol, host, port);
const paths = getPaths();
const config = createConfig(paths);
const compiler = webpack(config);
const server = new WebpackDevServer(compiler, {
hot: true,
publicPath: '/',
quiet: true,
https: protocol === 'https',
host,
port,
});
await new Promise((resolve, reject) => {
server.listen(port, host, (err?: Error) => {
if (err) {
reject(err);
return;
}
openBrowser(urls.localUrlForBrowser);
resolve();
});
});
}
@@ -0,0 +1,20 @@
import { spawn } from 'child_process';
import { createLogger } from './logger';
export function startChild(args: string[]) {
const [command, ...commandArgs] = args;
const child = spawn(command, commandArgs, {
env: { FORCE_COLOR: 'true', ...process.env },
stdio: ['inherit', 'pipe', 'pipe'],
});
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
const log = createLogger();
child.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
child.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
}
@@ -0,0 +1,50 @@
import { spawn } from 'child_process';
import { Logger } from './logger';
import chalk from 'chalk';
import { Package } from './packages';
export function startCompiler(pkg: Package, log: Logger) {
// First we figure out which yarn script is a available, falling back to "build --watch"
const scriptName = ['build:watch', 'watch'].find(
script => script in pkg.scripts,
);
const args = scriptName ? [scriptName] : ['build', '--watch'];
// Start the watch script inside the dependency
const watch = spawn('yarn', ['run', ...args], {
cwd: pkg.location,
env: { FORCE_COLOR: 'true', ...process.env },
stdio: 'pipe',
});
watch.stdin.end();
watch.stdout!.on('data', (data: Buffer) => {
log.out(data.toString('utf8'));
});
watch.stderr!.on('data', data => {
log.err(data.toString('utf8'));
});
const promise = new Promise<void>((resolve, reject) => {
watch.on('error', error => {
reject(error);
});
watch.on('close', (code: number) => {
if (code !== 0) {
const msg = `Compiler exited with code ${code}`;
log.err(chalk.red(msg));
reject(new Error(msg));
} else {
resolve();
}
});
});
return {
promise,
close() {
watch.kill('SIGINT');
},
};
}
@@ -0,0 +1,56 @@
import { resolve as resolvePath } from 'path';
import chalk from 'chalk';
import { createLoggerFactory } from './logger';
import { getPackageDeps } from './packages';
import { startWatcher, startPackageWatcher } from './watcher';
import { startCompiler } from './compiler';
import { startChild } from './child';
const PACKAGE_BLACKLIST = [
// We never want to watch for changes in the cli, but all packages will depend on it.
'@spotify-backstage/cli',
];
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
/*
* The watch-deps command is meant to improve iteration speed while working in a large monorepo
* with packages that are built independently, meaning packages depends on each other's build output.
*
* The command traverses all dependencies of the current package within the monorepo, and starts
* watching for updates in all those packages. If a change is detected, we stop listening for changes,
* and instead start up watch mode for that package. Starting watch mode means running the first
* available yarn script out of "build:watch", "watch", or "build" --watch.
*/
export default async (_command: any, args: string[]) => {
const localPackagePath = resolvePath('package.json');
// Rotate through different prefix colors to make it easier to differenciate between different deps
const logFactory = createLoggerFactory([
chalk.yellow,
chalk.blue,
chalk.magenta,
chalk.green,
chalk.cyan,
]);
// Find all direct and transitive local dependencies of the current package.
const deps = await getPackageDeps(localPackagePath, PACKAGE_BLACKLIST);
// We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected
const watcher = await startWatcher(deps, WATCH_LOCATIONS, pkg => {
startCompiler(pkg, logFactory(pkg.name)).promise.catch(error => {
process.stderr.write(`${error}\n`);
});
});
await startPackageWatcher(localPackagePath, async () => {
const newDeps = await getPackageDeps(localPackagePath, PACKAGE_BLACKLIST);
await watcher.update(newDeps);
});
if (args?.length) {
startChild(args);
}
};
@@ -0,0 +1,38 @@
export type Logger = {
out(msg: string): void;
err(msg: string): void;
};
export type ColorFunc = (msg: string) => string;
// Logger utility that prefixes logs and removes terminal clear commands
export function createLogger(prefix: string = ''): Logger {
const write = (stream: NodeJS.WriteStream, msg: string) => {
const noClearMsg = msg.startsWith('\x1b\x63') ? msg.slice(2) : msg;
const prefixedMsg = noClearMsg.trimRight().replace(/^/gm, prefix);
stream.write(`${prefixedMsg}\n`, 'utf8');
};
return {
out(msg: string) {
write(process.stdout, msg);
},
err(msg: string) {
write(process.stderr, msg);
},
};
}
// A factory for creating loggers that rotate between different coloring functions
export function createLoggerFactory(colorFuncs: ColorFunc[]) {
let colorIndex = 0;
return (name: string) => {
const colorFunc = colorFuncs[colorIndex];
colorIndex = (colorIndex + 1) % colorFuncs.length;
const prefix = `${colorFunc(name)}: `;
return createLogger(prefix);
};
}
@@ -0,0 +1,57 @@
import { resolve as resolvePath } from 'path';
import fs from 'fs';
import { promisify } from 'util';
const readFile = promisify(fs.readFile);
const LernaProject = require('@lerna/project');
const PackageGraph = require('@lerna/package-graph');
export type Package = {
name: string;
location: string;
scripts: { [name in string]: string };
};
// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist
export async function findAllDeps(
rootPackageName: string,
blacklist: string[],
): Promise<Package[]> {
const project = new LernaProject(resolvePath('.'));
const packages = await project.getPackages();
const graph = new PackageGraph(packages);
const deps = new Map<string, any>();
const searchNames = [rootPackageName];
while (searchNames.length) {
const name = searchNames.pop()!;
if (deps.has(name)) {
continue;
}
const node = graph.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
searchNames.push(...node.localDependencies.keys());
deps.set(name, node.pkg);
}
deps.delete(rootPackageName);
for (const name of blacklist) {
deps.delete(name);
}
return [...deps.values()];
}
export async function getPackageDeps(packagePath: string, blacklist: string[]) {
const packageData = await readFile(packagePath, 'utf8');
const packageJson = JSON.parse(packageData);
return await findAllDeps(packageJson.name, blacklist);
}
@@ -0,0 +1,109 @@
import { resolve as resolvePath } from 'path';
import chalk from 'chalk';
import chokidar from 'chokidar';
import { Package } from './packages';
import { createLogger } from './logger';
export type Watcher = {
update(newPackages: Package[]): Promise<void>;
};
/*
* Watch for changes inside a collection of packages. When a change is detected, stop
* watching and call the callback with the package the change occured in.
*
* The returned promise is resolved once all watchers are ready.
*/
export async function startWatcher(
packages: Package[],
paths: string[],
callback: (pkg: Package) => void,
): Promise<Watcher> {
const watchedPackageLocations = new Set<string>();
const logger = createLogger();
const watchPackage = async (pkg: Package) => {
let signalled = false;
watchedPackageLocations.add(pkg.location);
const watchLocations = paths.map(path => resolvePath(pkg.location, path));
const watcher = chokidar
.watch(watchLocations, {
cwd: pkg.location,
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
if (!signalled) {
signalled = true;
callback(pkg);
}
watcher.close();
});
return new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
});
};
const update = async (newPackages: Package[]) => {
const promises = new Array<Promise<unknown>>();
for (const pkg of newPackages) {
if (watchedPackageLocations.has(pkg.location)) {
continue;
}
logger.out(chalk.green(`Starting watch of new dependency ${pkg.name}`));
promises.push(watchPackage(pkg));
}
await Promise.all(promises);
};
await Promise.all(packages.map(watchPackage));
return { update };
}
/**
* Watch a package.json for updates
*/
export function startPackageWatcher(packagePath: string, callback: () => void) {
let changed = false;
let working = false;
const notifyDeps = async () => {
changed = true;
if (working) {
return;
}
working = true;
changed = false;
try {
await callback();
} finally {
working = false;
// Keep going if a change was emitted while working
if (changed) {
notifyDeps();
}
}
};
const watcher = chokidar
.watch(packagePath, {
ignoreInitial: true,
disableGlobbing: true,
})
.on('all', () => {
notifyDeps();
});
return new Promise((resolve, reject) => {
watcher.on('ready', resolve);
watcher.on('error', reject);
});
}
+5
View File
@@ -0,0 +1,5 @@
describe('dummy', () => {
it('dummy', () => {
expect(1).toBe(1);
});
});
+50
View File
@@ -0,0 +1,50 @@
import program from 'commander';
import chalk from 'chalk';
import fs from 'fs';
import createPluginCommand from './commands/createPlugin';
import watch from './commands/watch-deps';
import serve from './commands/serve';
process.on('unhandledRejection', err => {
throw err;
});
const main = (argv: string[]) => {
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
program.name('backstage-cli').version(packageJson.version ?? '0.0.0');
program
.command('create-plugin')
.description('Creates a new plugin in the current repository')
.action(createPluginCommand);
program
.command('serve')
.description('Serves the dev/ folder of a package')
.action(serve);
program
.command('watch-deps')
.description('Watch all dependencies while running another command')
.action(watch);
program.on('command:*', () => {
console.log();
console.log(
chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`),
);
console.log(chalk.red('See --help for a list of available commands.'));
console.log();
process.exit(1);
});
if (!process.argv.slice(2).length) {
program.outputHelp(chalk.yellow);
}
program.parse(argv);
};
main(process.argv);
// main([process.argv[0], process.argv[1], '--version']);
@@ -0,0 +1,6 @@
# Title
Welcome to your {{id}} plugin!
## Sub-section 1
## Sub-section 2
@@ -0,0 +1,4 @@
module.exports = {
...require('@spotify/web-scripts/config/jest.config.js'),
setupFilesAfterEnv: ['../jest.setup.ts'],
};
@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';
@@ -0,0 +1,19 @@
{
"name": "@spotify-backstage/plugin-{{id}}",
"version": "0.0.0",
"main": "dist/cjs",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
"lint": "web-scripts lint",
"test": "web-scripts test"
},
"devDependencies": {
"@spotify-backstage/cli": "^1.2.0",
"@spotify/web-scripts": "^6.0.0"
},
"dependencies": {
"@material-ui/lab": "4.0.0-alpha.45"
}
}
@@ -0,0 +1,10 @@
import React from 'react';
import { render } from '@testing-library/react';
import ExampleComponent from './ExampleComponent';
describe('ExampleComponent', () => {
it('should render', () => {
const rendered = render(<ExampleComponent />);
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
import React, { FC } from 'react';
import { Typography, Grid, makeStyles, Theme } from '@material-ui/core';
import { InfoCard, Header, Page, theme } from '@spotify-backstage/core';
import ExampleFetchComponent from '../ExampleFetchComponent';
const useStyles = makeStyles<Theme>(theme => ({
mainContentArea: {
overflowX: 'hidden',
overflowY: 'auto',
},
pageBody: {
padding: theme.spacing(3),
},
title: {
padding: theme.spacing(1,0,2,0),
},
}));
const ExampleComponent: FC<{}> = () => {
const classes = useStyles();
return (
<Page theme={theme.tool}>
<div className={classes.mainContentArea}>
<Header title="Welcome to {{ id }}!" subtitle="Optional subtitle"></Header>
<Grid
container
spacing={3}
direction={'column'}
className={classes.pageBody}
>
<Grid item>
<Typography variant="h3">Plugin page title</Typography>
</Grid>
<Grid item>
<InfoCard title="Information card" maxWidth>
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<InfoCard title="Example User List (fetching data from randomuser.me)">
<ExampleFetchComponent />
</InfoCard>
</Grid>
</Grid>
</div>
</Page>
);
};
export default ExampleComponent;
@@ -0,0 +1 @@
export { default } from './ExampleComponent';
@@ -0,0 +1,10 @@
import React from 'react';
import { render } from '@testing-library/react';
import ExampleFetchComponent from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
it('should render', () => {
const rendered = render(<ExampleFetchComponent />);
expect(rendered.getByTestId('progress')).toBeInTheDocument();
});
});
@@ -0,0 +1,90 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import LinearProgress from '@material-ui/core/LinearProgress';
import Alert from '@material-ui/lab/Alert';
import { useAsync } from 'react-use';
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
type User = {
gender: string; // "male"
name: {
title: string; //"Mr",
first: string; // "Duane",
last: string; //"Reed"
};
location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…}
email: string; // "duane.reed@example.com"
login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…}
dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37}
registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14}
phone: string; //"07-2154-5651"
cell: string; // "0405-592-879"
id: {
name: string; // "TFN",
value: string; // "796260432"
};
picture: object; //{large: "https://randomuser.me/api/portraits/men/95.jpg",…}
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable: FC<DenseTableProps> = ({ users }) => {
const classes = useStyles();
return (
<TableContainer>
<Table className={classes.table} size="small" aria-label="a dense table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Nationality</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map(user => (
<TableRow key={user.email}>
<TableCell>
{user.name.first} {user.name.last}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.nat}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
const ExampleFetchComponent: FC<{}> = () => {
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
return data.results;
}, []);
if (loading) {
return <LinearProgress data-testid="progress" />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
} else {
return <DenseTable users={value || []} />;
}
};
export default ExampleFetchComponent;
@@ -0,0 +1 @@
export { default } from './ExampleFetchComponent';
@@ -0,0 +1 @@
export { default } from './plugin';
@@ -0,0 +1,7 @@
import plugin from './plugin';
describe('{{ id }}', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
@@ -0,0 +1,9 @@
import { createPlugin } from '@spotify-backstage/core';
import ExampleComponent from './components/ExampleComponent';
export default createPlugin({
id: '{{ id }}',
register({ router }) {
router.registerRoute('/{{ id }}', ExampleComponent);
},
});
@@ -0,0 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src"]
}
+27
View File
@@ -0,0 +1,27 @@
<!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"
/>
<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>
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@spotify/web-scripts/config/tsconfig.json",
"include": ["src"],
"compilerOptions": {
"baseUrl": "src",
"paths": {
"*": ["src/*"]
}
}
}