Address review feedback from freben

- Use cli-defaults instead of listing individual CLI modules in
  create-app template and root package.json
- Move nodeTransform config files from cli-module-build to cli-node
  to avoid cross-module direct imports
- Rename cli-module-create-github-app to cli-module-github
- Start createCliModule init chain with Promise.resolve()
- Deduplicate exitWithError in runCliModule.ts
- Extract shared isCommandNodeHidden to @internal/cli
- Add explanatory comment for fromArray deduplication field
- Restore error for cli role packages missing bin in runCliExtraction

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 12:55:22 +01:00
parent 26eab3bf83
commit 4d081452b1
47 changed files with 147 additions and 185 deletions
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2024 The Backstage Authors
*
* 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 { runCliModule } from '@backstage/cli-node';
import cliModule from './index';
const pkg = require('../package.json') as { name: string; version: string };
runCliModule({
module: cliModule,
name: pkg.name,
version: pkg.version,
});
@@ -0,0 +1,166 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 'node:crypto';
import openBrowser from 'react-dev-utils/openBrowser';
import { request } from '@octokit/request';
import express, { Express, Request, Response } from 'express';
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;
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(options: {
org: string;
permissions: string[];
}): Promise<GithubAppConfig> {
const encodedOrg = encodeURIComponent(options.org);
const actionUrl = `https://github.com/organizations/${encodedOrg}/settings/apps/new`;
const server = new GithubCreateAppServer(actionUrl, options.permissions);
return server.start();
}
private readonly actionUrl: string;
private readonly permissions: string[];
private constructor(actionUrl: string, permissions: string[]) {
this.actionUrl = actionUrl;
this.permissions = permissions;
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 }) => {
resolve({
name: data.name,
slug: data.slug,
appId: data.id,
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 = {
default_events: ['create', 'delete', 'push', 'repository'],
default_permissions: {
metadata: 'read',
...(this.permissions.includes('members') && {
members: 'read',
}),
...(this.permissions.includes('read') && {
contents: 'read',
checks: 'read',
}),
...(this.permissions.includes('write') && {
contents: 'write',
checks: 'read',
actions: 'write',
}),
},
name: 'Backstage-<changeme>',
url: 'https://backstage.io',
description: 'GitHub App for Backstage',
public: false,
redirect_url: `${baseUrl}/callback`,
hook_attributes: {
url: this.webhookUrl,
active: false,
},
};
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,146 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 inquirer, { Question, Answers } from 'inquirer';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import { GithubCreateAppServer } from './GithubCreateAppServer';
import openBrowser from 'react-dev-utils/openBrowser';
import type { CliCommandContext } from '@backstage/cli-node';
// 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 ({ args, info }: CliCommandContext) => {
const { _: positionals } = cli(
{
help: { ...info, usage: `${info.usage} <github-org>` },
booleanFlagNegation: true,
parameters: ['<github-org>'],
},
undefined,
args,
);
const org = positionals[0];
const answers: Answers = await inquirer.prompt({
name: 'appType',
type: 'checkbox',
message:
'Select permissions [required] (these can be changed later but then require approvals in all installations)',
choices: [
{
name: 'Read access to content (required by Software Catalog to ingest data from repositories)',
value: 'read',
checked: true,
},
{
name: 'Read access to members (required by Software Catalog to ingest GitHub teams)',
value: 'members',
checked: true,
},
{
name: 'Read and Write to content and actions (required by Software Templates to create new repositories)',
value: 'write',
},
],
});
if (answers.appType.length === 0) {
console.log(chalk.red('You must select at least one permission'));
process.exit(1);
}
await verifyGithubOrg(org);
const { slug, name, ...config } = await GithubCreateAppServer.run({
org,
permissions: answers.appType,
});
const fileName = `github-app-${slug}-credentials.yaml`;
const content = `# Name: ${name}\n${stringifyYaml(config)}`;
await fs.writeFile(targetPaths.resolveRoot(fileName), content);
console.log(`GitHub App configuration written to ${chalk.cyan(fileName)}`);
console.log(
chalk.yellow(
'This file contains sensitive credentials, it should not be committed to version control and handled with care!',
),
);
console.log(
"Here's an example on how to update the integrations section in app-config.yaml",
);
console.log(
chalk.green(`
integrations:
github:
- host: github.com
apps:
- $include: ${fileName}`),
);
};
async function verifyGithubOrg(org: string): Promise<void> {
let response;
try {
response = await fetch(
`https://api.github.com/orgs/${encodeURIComponent(org)}`,
);
} catch (e) {
console.log(
chalk.yellow(
'Warning: Unable to verify existence of GitHub organization. ',
e,
),
);
}
if (response?.status === 404) {
const questions: Question[] = [
{
type: 'confirm',
name: 'shouldCreateOrg',
message: `GitHub organization ${chalk.cyan(
org,
)} does not exist. Would you like to create a new Organization instead?`,
},
];
const answers = await inquirer.prompt(questions);
if (!answers.shouldCreateOrg) {
console.log(
chalk.yellow('GitHub organization must exist to create GitHub app'),
);
process.exit(1);
}
openBrowser('https://github.com/account/organizations/new');
console.log(
chalk.yellow(
'Please re-run this command when you have created your new organization',
),
);
process.exit(0);
}
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['create-github-app'],
description: 'Create new GitHub App in your organization.',
execute: { loader: () => import('./commands/create-github-app') },
});
},
});