|
| 1 | +#!/usr/bin/env tsx |
| 2 | + |
| 3 | +/** |
| 4 | + * GitHub App Installation Token Generator |
| 5 | + * |
| 6 | + * Generates a temporary installation token for a GitHub App to access a specific repository. |
| 7 | + * Uses environment variables for configuration and supports multiple installations. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * node scripts/generate-installation-token.ts [--repo owner/name] [--update-env] |
| 11 | + * |
| 12 | + * Environment variables required: |
| 13 | + * GITHUB_APP_ID - GitHub App ID |
| 14 | + * GITHUB_PRIVATE_KEY - GitHub App private key (PEM format) |
| 15 | + * REPO_OWNER - Target repository owner (default) |
| 16 | + * REPO_NAME - Target repository name (default) |
| 17 | + */ |
| 18 | + |
| 19 | +import { createSign } from "node:crypto"; |
| 20 | +import { readFileSync, writeFileSync } from "node:fs"; |
| 21 | +import { join } from "node:path"; |
| 22 | +import { config } from "dotenv"; |
| 23 | + |
| 24 | +// Load environment variables |
| 25 | +config(); |
| 26 | + |
| 27 | +interface GitHubAppConfig { |
| 28 | + appId: string; |
| 29 | + privateKey: string; |
| 30 | + repoOwner: string; |
| 31 | + repoName: string; |
| 32 | +} |
| 33 | + |
| 34 | +interface Installation { |
| 35 | + id: number; |
| 36 | + account: { |
| 37 | + login: string; |
| 38 | + type: string; |
| 39 | + }; |
| 40 | +} |
| 41 | + |
| 42 | +interface Repository { |
| 43 | + owner: { |
| 44 | + login: string; |
| 45 | + }; |
| 46 | + name: string; |
| 47 | +} |
| 48 | + |
| 49 | +interface InstallationTokenResponse { |
| 50 | + token: string; |
| 51 | + expires_at: string; |
| 52 | +} |
| 53 | + |
| 54 | +interface RepositoriesResponse { |
| 55 | + repositories: Repository[]; |
| 56 | +} |
| 57 | + |
| 58 | +class GitHubAppTokenGenerator { |
| 59 | + private config: GitHubAppConfig; |
| 60 | + |
| 61 | + constructor(config: GitHubAppConfig) { |
| 62 | + // Process private key to handle escaped newlines |
| 63 | + config.privateKey = config.privateKey.replace(/\\n/g, "\n"); |
| 64 | + this.config = config; |
| 65 | + this.validateConfig(); |
| 66 | + } |
| 67 | + |
| 68 | + private validateConfig(): void { |
| 69 | + const { appId, privateKey, repoOwner, repoName } = this.config; |
| 70 | + |
| 71 | + if (!appId) { |
| 72 | + throw new Error("GITHUB_APP_ID environment variable is required"); |
| 73 | + } |
| 74 | + |
| 75 | + if (!privateKey) { |
| 76 | + throw new Error("GITHUB_PRIVATE_KEY environment variable is required"); |
| 77 | + } |
| 78 | + |
| 79 | + if (!repoOwner || !repoName) { |
| 80 | + throw new Error("REPO_OWNER and REPO_NAME environment variables are required"); |
| 81 | + } |
| 82 | + |
| 83 | + if (!privateKey.includes("BEGIN") || !privateKey.includes("END")) { |
| 84 | + throw new Error("GITHUB_PRIVATE_KEY must be in PEM format"); |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + /** |
| 89 | + * Generates a JWT for GitHub App authentication |
| 90 | + */ |
| 91 | + private generateJWT(): string { |
| 92 | + const now = Math.floor(Date.now() / 1000); |
| 93 | + const payload = { |
| 94 | + iat: now - 60, // issued 1 minute ago to account for clock skew |
| 95 | + exp: now + 5 * 60, // expires in 5 minutes |
| 96 | + iss: this.config.appId, |
| 97 | + }; |
| 98 | + |
| 99 | + const header = { |
| 100 | + alg: "RS256", |
| 101 | + typ: "JWT", |
| 102 | + }; |
| 103 | + |
| 104 | + const encodedHeader = this.base64UrlEncode(JSON.stringify(header)); |
| 105 | + const encodedPayload = this.base64UrlEncode(JSON.stringify(payload)); |
| 106 | + const signaturePart = `${encodedHeader}.${encodedPayload}`; |
| 107 | + |
| 108 | + const signature = createSign("RSA-SHA256") |
| 109 | + .update(signaturePart) |
| 110 | + .sign(this.config.privateKey, "base64") |
| 111 | + .replace(/\+/g, "-") |
| 112 | + .replace(/\//g, "_") |
| 113 | + .replace(/=/g, ""); |
| 114 | + |
| 115 | + return `${signaturePart}.${signature}`; |
| 116 | + } |
| 117 | + |
| 118 | + private base64UrlEncode(str: string): string { |
| 119 | + return Buffer.from(str) |
| 120 | + .toString("base64") |
| 121 | + .replace(/\+/g, "-") |
| 122 | + .replace(/\//g, "_") |
| 123 | + .replace(/=/g, ""); |
| 124 | + } |
| 125 | + |
| 126 | + /** |
| 127 | + * Makes authenticated requests to GitHub API |
| 128 | + */ |
| 129 | + private async githubRequest<T>( |
| 130 | + path: string, |
| 131 | + options: { |
| 132 | + method?: string; |
| 133 | + headers?: Record<string, string>; |
| 134 | + body?: string; |
| 135 | + } = {} |
| 136 | + ): Promise<T> { |
| 137 | + const { method = "GET", headers = {}, body } = options; |
| 138 | + |
| 139 | + const url = `https://api.github.com${path}`; |
| 140 | + const requestHeaders = { |
| 141 | + Accept: "application/vnd.github.v3+json", |
| 142 | + "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", |
| 143 | + ...headers, |
| 144 | + }; |
| 145 | + |
| 146 | + const response = await fetch(url, { |
| 147 | + method, |
| 148 | + headers: requestHeaders, |
| 149 | + ...(body && { body }), |
| 150 | + }); |
| 151 | + |
| 152 | + if (!response.ok) { |
| 153 | + const errorText = await response.text(); |
| 154 | + throw new Error( |
| 155 | + `GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}` |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + return response.json() as T; |
| 160 | + } |
| 161 | + |
| 162 | + /** |
| 163 | + * Finds the installation ID for the target repository |
| 164 | + */ |
| 165 | + private async findInstallationId(jwt: string): Promise<number> { |
| 166 | + console.log("🔍 Finding GitHub App installation..."); |
| 167 | + |
| 168 | + const installations = await this.githubRequest<Installation[]>("/app/installations", { |
| 169 | + headers: { Authorization: `Bearer ${jwt}` }, |
| 170 | + }); |
| 171 | + |
| 172 | + console.log(`📋 Found ${installations.length} installation(s)`); |
| 173 | + |
| 174 | + // Check each installation for access to target repository |
| 175 | + for (const installation of installations) { |
| 176 | + console.log(`🔎 Checking installation ${installation.id} (${installation.account.login})`); |
| 177 | + |
| 178 | + try { |
| 179 | + // Create a temporary token to check repository access |
| 180 | + const tempToken = await this.createInstallationToken(jwt, installation.id); |
| 181 | + const hasAccess = await this.checkRepositoryAccess(tempToken); |
| 182 | + |
| 183 | + if (hasAccess) { |
| 184 | + console.log( |
| 185 | + `✅ Installation ${installation.id} has access to ${this.config.repoOwner}/${this.config.repoName}` |
| 186 | + ); |
| 187 | + return installation.id; |
| 188 | + } |
| 189 | + } catch (error) { |
| 190 | + console.log( |
| 191 | + `❌ Installation ${installation.id} check failed:`, |
| 192 | + error instanceof Error ? error.message : String(error) |
| 193 | + ); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + throw new Error( |
| 198 | + `No installation found with access to ${this.config.repoOwner}/${this.config.repoName}. ` + |
| 199 | + "Ensure the GitHub App is installed on the target repository." |
| 200 | + ); |
| 201 | + } |
| 202 | + |
| 203 | + /** |
| 204 | + * Checks if the installation token has access to the target repository |
| 205 | + */ |
| 206 | + private async checkRepositoryAccess(token: string): Promise<boolean> { |
| 207 | + try { |
| 208 | + const response = await this.githubRequest<RepositoriesResponse>( |
| 209 | + "/installation/repositories", |
| 210 | + { |
| 211 | + headers: { Authorization: `token ${token}` }, |
| 212 | + } |
| 213 | + ); |
| 214 | + |
| 215 | + return response.repositories.some( |
| 216 | + (repo) => repo.owner.login === this.config.repoOwner && repo.name === this.config.repoName |
| 217 | + ); |
| 218 | + } catch { |
| 219 | + return false; |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * Creates an installation access token |
| 225 | + */ |
| 226 | + private async createInstallationToken(jwt: string, installationId: number): Promise<string> { |
| 227 | + const response = await this.githubRequest<InstallationTokenResponse>( |
| 228 | + `/app/installations/${installationId}/access_tokens`, |
| 229 | + { |
| 230 | + method: "POST", |
| 231 | + headers: { Authorization: `Bearer ${jwt}` }, |
| 232 | + } |
| 233 | + ); |
| 234 | + |
| 235 | + return response.token; |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Generates a new installation token for the configured repository |
| 240 | + */ |
| 241 | + async generateToken(): Promise<{ |
| 242 | + token: string; |
| 243 | + installationId: number; |
| 244 | + expiresAt: string; |
| 245 | + }> { |
| 246 | + console.log( |
| 247 | + `🚀 Generating installation token for ${this.config.repoOwner}/${this.config.repoName}` |
| 248 | + ); |
| 249 | + console.log(`📱 App ID: ${this.config.appId}`); |
| 250 | + |
| 251 | + // Step 1: Generate JWT for app authentication |
| 252 | + const jwt = this.generateJWT(); |
| 253 | + console.log("🔐 Generated JWT token"); |
| 254 | + |
| 255 | + // Step 2: Find installation with repository access |
| 256 | + const installationId = await this.findInstallationId(jwt); |
| 257 | + |
| 258 | + // Step 3: Create installation access token |
| 259 | + console.log(`🎫 Creating installation token for installation ${installationId}...`); |
| 260 | + const token = await this.createInstallationToken(jwt, installationId); |
| 261 | + |
| 262 | + // Calculate expiration (GitHub tokens expire after 1 hour) |
| 263 | + const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); |
| 264 | + |
| 265 | + console.log("✅ Installation token generated successfully!"); |
| 266 | + console.log(`🎟️ Token: ${token.substring(0, 20)}...`); |
| 267 | + console.log(`📅 Expires: ${expiresAt}`); |
| 268 | + console.log(`🏢 Installation ID: ${installationId}`); |
| 269 | + |
| 270 | + return { token, installationId, expiresAt }; |
| 271 | + } |
| 272 | + |
| 273 | + /** |
| 274 | + * Updates the .env file with the new installation token |
| 275 | + */ |
| 276 | + updateEnvFile(token: string): void { |
| 277 | + const envPath = join(process.cwd(), ".env"); |
| 278 | + |
| 279 | + try { |
| 280 | + let envContent = readFileSync(envPath, "utf8"); |
| 281 | + |
| 282 | + // Update or add the installation token |
| 283 | + const tokenLine = `GITHUB_INSTALLATION_TOKEN=${token}`; |
| 284 | + const tokenRegex = /^GITHUB_INSTALLATION_TOKEN=.*$/m; |
| 285 | + |
| 286 | + if (tokenRegex.test(envContent)) { |
| 287 | + envContent = envContent.replace(tokenRegex, tokenLine); |
| 288 | + } else { |
| 289 | + envContent += `\n${tokenLine}\n`; |
| 290 | + } |
| 291 | + |
| 292 | + writeFileSync(envPath, envContent); |
| 293 | + console.log(`📝 Updated ${envPath} with new installation token`); |
| 294 | + } catch (error) { |
| 295 | + console.error( |
| 296 | + "❌ Failed to update .env file:", |
| 297 | + error instanceof Error ? error.message : String(error) |
| 298 | + ); |
| 299 | + } |
| 300 | + } |
| 301 | +} |
| 302 | + |
| 303 | +/** |
| 304 | + * CLI interface |
| 305 | + */ |
| 306 | +async function main(): Promise<void> { |
| 307 | + try { |
| 308 | + const args = process.argv.slice(2); |
| 309 | + const updateEnv = args.includes("--update-env"); |
| 310 | + |
| 311 | + // Parse repository from args if provided |
| 312 | + const repoArg = args.find((arg) => arg.startsWith("--repo=")); |
| 313 | + let repoOwner = process.env.REPO_OWNER || "pullfrogai"; |
| 314 | + let repoName = process.env.REPO_NAME || "scratch"; |
| 315 | + |
| 316 | + if (repoArg) { |
| 317 | + const [owner, name] = repoArg.split("=")[1].split("/"); |
| 318 | + if (owner && name) { |
| 319 | + repoOwner = owner; |
| 320 | + repoName = name; |
| 321 | + } else { |
| 322 | + throw new Error("Invalid --repo format. Use: --repo=owner/name"); |
| 323 | + } |
| 324 | + } |
| 325 | + |
| 326 | + const config: GitHubAppConfig = { |
| 327 | + appId: process.env.GITHUB_APP_ID!, |
| 328 | + privateKey: process.env.GITHUB_PRIVATE_KEY!, |
| 329 | + repoOwner, |
| 330 | + repoName, |
| 331 | + }; |
| 332 | + |
| 333 | + const generator = new GitHubAppTokenGenerator(config); |
| 334 | + const result = await generator.generateToken(); |
| 335 | + |
| 336 | + if (updateEnv) { |
| 337 | + generator.updateEnvFile(result.token); |
| 338 | + } |
| 339 | + |
| 340 | + console.log("\n🎉 Token generation complete!"); |
| 341 | + |
| 342 | + if (!updateEnv) { |
| 343 | + console.log("\n💡 To automatically update your .env file, run with --update-env flag"); |
| 344 | + } |
| 345 | + } catch (error) { |
| 346 | + console.error("❌ Error:", error instanceof Error ? error.message : String(error)); |
| 347 | + process.exit(1); |
| 348 | + } |
| 349 | +} |
| 350 | + |
| 351 | +// Run if called directly |
| 352 | +if (import.meta.url === `file://${process.argv[1]}`) { |
| 353 | + main(); |
| 354 | +} |
| 355 | + |
| 356 | +export { GitHubAppTokenGenerator }; |
0 commit comments