Skip to content

Commit f0e339f

Browse files
committed
big
1 parent 87d3276 commit f0e339f

2 files changed

Lines changed: 357 additions & 5 deletions

File tree

‎mcp/server.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,7 @@ server.tool(
3535
},
3636
async ({ issueNumber, body }) => {
3737
try {
38-
// Validate input using arktype
39-
const validation = Comment({ issueNumber, body });
40-
if (validation instanceof type.errors) {
41-
throw new Error(`Invalid input: ${validation.summary}`);
42-
}
38+
Comment.assert({ issueNumber, body });
4339

4440
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
4541
if (!githubInstallationToken) {
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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

Comments
 (0)