Merge pull request #3919 from backstage/mob/app-cli

cli: added experimental create-github-app command
This commit is contained in:
Johan Haals
2021-01-11 11:06:46 +01:00
committed by GitHub
5 changed files with 201 additions and 0 deletions
@@ -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, '&quot;');
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,34 @@
/*
* 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';
// This is an experimental command that at this point does not support GitHub Enterprise
// due to lacking support for creating apps from manifests.
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
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.
};
+7
View File
@@ -205,6 +205,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