Skip to content

Commit

Permalink
cli: added experimental create-github-app command
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Lambert <ben@blam.sh>
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
  • Loading branch information
3 people committed Jan 5, 2021
1 parent cf2c2df commit 9cf71f8
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/spicy-feet-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---

Added experimental `create-github-app` command.
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
152 changes: 152 additions & 0 deletions packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
}
}
31 changes: 31 additions & 0 deletions packages/cli/src/commands/create-github-app/index.ts
Original file line number Diff line number Diff line change
@@ -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.
};
7 changes: 7 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 9cf71f8

Please sign in to comment.