cli: add create-app

This commit is contained in:
Patrik Oldsberg
2020-03-24 10:23:43 +01:00
parent 841a387417
commit 111f163d41
35 changed files with 683 additions and 0 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!');
}
};
+6
View File
@@ -17,6 +17,7 @@
import program from 'commander';
import chalk from 'chalk';
import fs from 'fs';
import createAppCommand from './commands/create-app/createApp';
import createPluginCommand from './commands/create-plugin/createPlugin';
import watch from './commands/watch-deps';
import lintCommand from './commands/lint';
@@ -32,6 +33,11 @@ const main = (argv: string[]) => {
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
}
}