From f0e339f5c21537a6e7a6a5fe73ef0cade4bd9eeb Mon Sep 17 00:00:00 2001 From: David Blass Date: Wed, 8 Oct 2025 16:21:10 -0400 Subject: [PATCH] big --- mcp/server.ts | 6 +- scripts/generate-installation-token.ts | 356 +++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 scripts/generate-installation-token.ts diff --git a/mcp/server.ts b/mcp/server.ts index caeb9f7..5c2ab49 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -35,11 +35,7 @@ server.tool( }, async ({ issueNumber, body }) => { try { - // Validate input using arktype - const validation = Comment({ issueNumber, body }); - if (validation instanceof type.errors) { - throw new Error(`Invalid input: ${validation.summary}`); - } + Comment.assert({ issueNumber, body }); const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; if (!githubInstallationToken) { diff --git a/scripts/generate-installation-token.ts b/scripts/generate-installation-token.ts new file mode 100644 index 0000000..f154525 --- /dev/null +++ b/scripts/generate-installation-token.ts @@ -0,0 +1,356 @@ +#!/usr/bin/env tsx + +/** + * GitHub App Installation Token Generator + * + * Generates a temporary installation token for a GitHub App to access a specific repository. + * Uses environment variables for configuration and supports multiple installations. + * + * Usage: + * node scripts/generate-installation-token.ts [--repo owner/name] [--update-env] + * + * Environment variables required: + * GITHUB_APP_ID - GitHub App ID + * GITHUB_PRIVATE_KEY - GitHub App private key (PEM format) + * REPO_OWNER - Target repository owner (default) + * REPO_NAME - Target repository name (default) + */ + +import { createSign } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { config } from "dotenv"; + +// Load environment variables +config(); + +interface GitHubAppConfig { + appId: string; + privateKey: string; + repoOwner: string; + repoName: string; +} + +interface Installation { + id: number; + account: { + login: string; + type: string; + }; +} + +interface Repository { + owner: { + login: string; + }; + name: string; +} + +interface InstallationTokenResponse { + token: string; + expires_at: string; +} + +interface RepositoriesResponse { + repositories: Repository[]; +} + +class GitHubAppTokenGenerator { + private config: GitHubAppConfig; + + constructor(config: GitHubAppConfig) { + // Process private key to handle escaped newlines + config.privateKey = config.privateKey.replace(/\\n/g, "\n"); + this.config = config; + this.validateConfig(); + } + + private validateConfig(): void { + const { appId, privateKey, repoOwner, repoName } = this.config; + + if (!appId) { + throw new Error("GITHUB_APP_ID environment variable is required"); + } + + if (!privateKey) { + throw new Error("GITHUB_PRIVATE_KEY environment variable is required"); + } + + if (!repoOwner || !repoName) { + throw new Error("REPO_OWNER and REPO_NAME environment variables are required"); + } + + if (!privateKey.includes("BEGIN") || !privateKey.includes("END")) { + throw new Error("GITHUB_PRIVATE_KEY must be in PEM format"); + } + } + + /** + * Generates a JWT for GitHub App authentication + */ + private generateJWT(): string { + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, // issued 1 minute ago to account for clock skew + exp: now + 5 * 60, // expires in 5 minutes + iss: this.config.appId, + }; + + const header = { + alg: "RS256", + typ: "JWT", + }; + + const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); + const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); + const signaturePart = `${encodedHeader}.${encodedPayload}`; + + const signature = createSign("RSA-SHA256") + .update(signaturePart) + .sign(this.config.privateKey, "base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + return `${signaturePart}.${signature}`; + } + + private base64UrlEncode(str: string): string { + return Buffer.from(str) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + } + + /** + * Makes authenticated requests to GitHub API + */ + private async githubRequest( + path: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {} + ): Promise { + const { method = "GET", headers = {}, body } = options; + + const url = `https://api.github.com${path}`; + const requestHeaders = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", + ...headers, + }; + + const response = await fetch(url, { + method, + headers: requestHeaders, + ...(body && { body }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}` + ); + } + + return response.json() as T; + } + + /** + * Finds the installation ID for the target repository + */ + private async findInstallationId(jwt: string): Promise { + console.log("šŸ” Finding GitHub App installation..."); + + const installations = await this.githubRequest("/app/installations", { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + console.log(`šŸ“‹ Found ${installations.length} installation(s)`); + + // Check each installation for access to target repository + for (const installation of installations) { + console.log(`šŸ”Ž Checking installation ${installation.id} (${installation.account.login})`); + + try { + // Create a temporary token to check repository access + const tempToken = await this.createInstallationToken(jwt, installation.id); + const hasAccess = await this.checkRepositoryAccess(tempToken); + + if (hasAccess) { + console.log( + `āœ… Installation ${installation.id} has access to ${this.config.repoOwner}/${this.config.repoName}` + ); + return installation.id; + } + } catch (error) { + console.log( + `āŒ Installation ${installation.id} check failed:`, + error instanceof Error ? error.message : String(error) + ); + } + } + + throw new Error( + `No installation found with access to ${this.config.repoOwner}/${this.config.repoName}. ` + + "Ensure the GitHub App is installed on the target repository." + ); + } + + /** + * Checks if the installation token has access to the target repository + */ + private async checkRepositoryAccess(token: string): Promise { + try { + const response = await this.githubRequest( + "/installation/repositories", + { + headers: { Authorization: `token ${token}` }, + } + ); + + return response.repositories.some( + (repo) => repo.owner.login === this.config.repoOwner && repo.name === this.config.repoName + ); + } catch { + return false; + } + } + + /** + * Creates an installation access token + */ + private async createInstallationToken(jwt: string, installationId: number): Promise { + const response = await this.githubRequest( + `/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` }, + } + ); + + return response.token; + } + + /** + * Generates a new installation token for the configured repository + */ + async generateToken(): Promise<{ + token: string; + installationId: number; + expiresAt: string; + }> { + console.log( + `šŸš€ Generating installation token for ${this.config.repoOwner}/${this.config.repoName}` + ); + console.log(`šŸ“± App ID: ${this.config.appId}`); + + // Step 1: Generate JWT for app authentication + const jwt = this.generateJWT(); + console.log("šŸ” Generated JWT token"); + + // Step 2: Find installation with repository access + const installationId = await this.findInstallationId(jwt); + + // Step 3: Create installation access token + console.log(`šŸŽ« Creating installation token for installation ${installationId}...`); + const token = await this.createInstallationToken(jwt, installationId); + + // Calculate expiration (GitHub tokens expire after 1 hour) + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + console.log("āœ… Installation token generated successfully!"); + console.log(`šŸŽŸļø Token: ${token.substring(0, 20)}...`); + console.log(`šŸ“… Expires: ${expiresAt}`); + console.log(`šŸ¢ Installation ID: ${installationId}`); + + return { token, installationId, expiresAt }; + } + + /** + * Updates the .env file with the new installation token + */ + updateEnvFile(token: string): void { + const envPath = join(process.cwd(), ".env"); + + try { + let envContent = readFileSync(envPath, "utf8"); + + // Update or add the installation token + const tokenLine = `GITHUB_INSTALLATION_TOKEN=${token}`; + const tokenRegex = /^GITHUB_INSTALLATION_TOKEN=.*$/m; + + if (tokenRegex.test(envContent)) { + envContent = envContent.replace(tokenRegex, tokenLine); + } else { + envContent += `\n${tokenLine}\n`; + } + + writeFileSync(envPath, envContent); + console.log(`šŸ“ Updated ${envPath} with new installation token`); + } catch (error) { + console.error( + "āŒ Failed to update .env file:", + error instanceof Error ? error.message : String(error) + ); + } + } +} + +/** + * CLI interface + */ +async function main(): Promise { + try { + const args = process.argv.slice(2); + const updateEnv = args.includes("--update-env"); + + // Parse repository from args if provided + const repoArg = args.find((arg) => arg.startsWith("--repo=")); + let repoOwner = process.env.REPO_OWNER || "pullfrogai"; + let repoName = process.env.REPO_NAME || "scratch"; + + if (repoArg) { + const [owner, name] = repoArg.split("=")[1].split("/"); + if (owner && name) { + repoOwner = owner; + repoName = name; + } else { + throw new Error("Invalid --repo format. Use: --repo=owner/name"); + } + } + + const config: GitHubAppConfig = { + appId: process.env.GITHUB_APP_ID!, + privateKey: process.env.GITHUB_PRIVATE_KEY!, + repoOwner, + repoName, + }; + + const generator = new GitHubAppTokenGenerator(config); + const result = await generator.generateToken(); + + if (updateEnv) { + generator.updateEnvFile(result.token); + } + + console.log("\nšŸŽ‰ Token generation complete!"); + + if (!updateEnv) { + console.log("\nšŸ’” To automatically update your .env file, run with --update-env flag"); + } + } catch (error) { + console.error("āŒ Error:", error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { GitHubAppTokenGenerator };