cli: added experimental create-github-app command
Co-authored-by: Ben Lambert <ben@blam.sh> Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Added experimental `create-github-app` command.
|
||||
@@ -34,6 +34,7 @@
|
||||
"@hot-loader/react-dom": "^16.13.0",
|
||||
"@lerna/package-graph": "^3.18.5",
|
||||
"@lerna/project": "^3.18.0",
|
||||
"@octokit/request": "^5.2.0",
|
||||
"@rollup/plugin-commonjs": "^16.0.0",
|
||||
"@rollup/plugin-json": "^4.0.2",
|
||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||
@@ -69,6 +70,7 @@
|
||||
"eslint-plugin-monorepo": "^0.3.2",
|
||||
"eslint-plugin-react": "^7.12.4",
|
||||
"eslint-plugin-react-hooks": "^4.0.0",
|
||||
"express": "^4.17.1",
|
||||
"fork-ts-checker-webpack-plugin": "^4.0.5",
|
||||
"fs-extra": "^9.0.0",
|
||||
"handlebars": "^4.7.3",
|
||||
@@ -118,6 +120,7 @@
|
||||
"@backstage/test-utils": "^0.1.6",
|
||||
"@backstage/theme": "^0.2.2",
|
||||
"@types/diff": "^4.0.2",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/html-webpack-plugin": "^3.2.2",
|
||||
"@types/http-proxy": "^1.17.4",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 crypto from 'crypto';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { request } from '@octokit/request';
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
|
||||
const MANIFEST_DATA = {
|
||||
default_events: ['create', 'delete', 'push', 'repository'],
|
||||
default_permissions: {
|
||||
contents: 'read',
|
||||
metadata: 'read',
|
||||
},
|
||||
name: 'Backstage-<changeme>',
|
||||
url: 'https://backstage.io',
|
||||
description: 'Github App for Backstage',
|
||||
public: false,
|
||||
};
|
||||
|
||||
const FORM_PAGE = `
|
||||
<html>
|
||||
<body>
|
||||
<form id="form" action="ACTION_URL" method="post">
|
||||
<input type="hidden" name="manifest" value="MANIFEST_JSON">
|
||||
<input type="submit" value="Continue">
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById("form").submit()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
type GithubAppConfig = {
|
||||
appId: number;
|
||||
apiUrl: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
webhookUrl?: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
webhookSecret: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export class GithubCreateAppServer {
|
||||
private baseUrl?: string;
|
||||
private webhookUrl?: string;
|
||||
|
||||
static async run({ org }: { org: string }): Promise<GithubAppConfig> {
|
||||
const encodedOrg = encodeURIComponent(org);
|
||||
const actionUrl = `https://github.com/organizations/${encodedOrg}/settings/apps/new`;
|
||||
const server = new GithubCreateAppServer(actionUrl);
|
||||
return server.start();
|
||||
}
|
||||
|
||||
constructor(private readonly actionUrl: string) {
|
||||
const webhookId = crypto
|
||||
.randomBytes(15)
|
||||
.toString('base64')
|
||||
.replace(/[\+\/]/g, '');
|
||||
|
||||
this.webhookUrl = `https://smee.io/${webhookId}`;
|
||||
}
|
||||
|
||||
private async start(): Promise<GithubAppConfig> {
|
||||
const app = express();
|
||||
|
||||
app.get('/', this.formHandler);
|
||||
|
||||
const callPromise = new Promise<GithubAppConfig>((resolve, reject) => {
|
||||
app.get('/callback', (req, res) => {
|
||||
request(
|
||||
`POST /app-manifests/${encodeURIComponent(
|
||||
req.query.code as string,
|
||||
)}/conversions`,
|
||||
).then(({ data, url }) => {
|
||||
// url = https://api.github.com/app-manifests/<code>/conversions
|
||||
const apiUrl = url.replace(/(?:\/[^\/]+){3}$/, '');
|
||||
resolve({
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
appId: data.id,
|
||||
apiUrl,
|
||||
webhookUrl: this.webhookUrl,
|
||||
clientId: data.client_id,
|
||||
clientSecret: data.client_secret,
|
||||
webhookSecret: data.webhook_secret,
|
||||
privateKey: data.pem,
|
||||
});
|
||||
res.redirect(302, `${data.html_url}/installations/new`);
|
||||
}, reject);
|
||||
});
|
||||
});
|
||||
|
||||
this.baseUrl = await this.listen(app);
|
||||
|
||||
openBrowser(this.baseUrl);
|
||||
|
||||
return callPromise;
|
||||
}
|
||||
|
||||
private formHandler = (_req: Request, res: Response) => {
|
||||
const baseUrl = this.baseUrl;
|
||||
if (!baseUrl) {
|
||||
throw new Error('baseUrl is not set');
|
||||
}
|
||||
const manifest = {
|
||||
...MANIFEST_DATA,
|
||||
redirect_url: `${baseUrl}/callback`,
|
||||
hook_attributes: {
|
||||
url: this.webhookUrl,
|
||||
},
|
||||
};
|
||||
const manifestJson = JSON.stringify(manifest).replace(/\"/g, '"');
|
||||
|
||||
let body = FORM_PAGE;
|
||||
body = body.replace('MANIFEST_JSON', manifestJson);
|
||||
body = body.replace('ACTION_URL', this.actionUrl);
|
||||
|
||||
res.setHeader('content-type', 'text/html');
|
||||
res.send(body);
|
||||
};
|
||||
|
||||
private async listen(app: Express) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const listener = app.listen(0, () => {
|
||||
const info = listener.address();
|
||||
if (typeof info !== 'object' || info === null) {
|
||||
reject(new Error(`Unexpected listener info '${info}'`));
|
||||
return;
|
||||
}
|
||||
const { port } = info;
|
||||
resolve(`http://localhost:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 chalk from 'chalk';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { GithubCreateAppServer } from './GithubCreateAppServer';
|
||||
|
||||
export default async (org: string) => {
|
||||
const { slug, name, ...config } = await GithubCreateAppServer.run({ org });
|
||||
|
||||
const fileName = `github-app-${slug}.yaml`;
|
||||
const content = `# Name: ${name}\n${stringifyYaml(config)}`;
|
||||
await fs.writeFile(paths.resolveTargetRoot(fileName), content);
|
||||
console.log(`GitHub App configuration written to ${chalk.cyan(fileName)}`);
|
||||
// TODO: log instructions on how to use the newly created app configuration.
|
||||
};
|
||||
@@ -202,6 +202,13 @@ export function registerCommands(program: CommanderStatic) {
|
||||
.command('build-workspace <workspace-dir> ...<packages>')
|
||||
.description('Builds a temporary dist workspace from the provided packages')
|
||||
.action(lazy(() => import('./buildWorkspace').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('create-github-app <github-org>', { hidden: true })
|
||||
.description(
|
||||
'Create new GitHub App in your organization. This command is experimental and may change in the future.',
|
||||
)
|
||||
.action(lazy(() => import('./create-github-app').then(m => m.default)));
|
||||
}
|
||||
|
||||
// Wraps an action function so that it always exits and handles errors
|
||||
|
||||
Reference in New Issue
Block a user