@fractary/faber-cli 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -4
- package/dist/commands/auth/index.d.ts +13 -0
- package/dist/commands/auth/index.d.ts.map +1 -0
- package/dist/commands/auth/index.js +291 -0
- package/dist/commands/init.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/lib/anthropic-client.js +1 -1
- package/dist/lib/github-app-auth.d.ts +122 -0
- package/dist/lib/github-app-auth.d.ts.map +1 -0
- package/dist/lib/github-app-auth.js +294 -0
- package/dist/lib/github-app-setup.d.ts +131 -0
- package/dist/lib/github-app-setup.d.ts.map +1 -0
- package/dist/lib/github-app-setup.js +241 -0
- package/dist/lib/repo-client.d.ts +22 -2
- package/dist/lib/repo-client.d.ts.map +1 -1
- package/dist/lib/repo-client.js +52 -8
- package/dist/lib/sdk-config-adapter.d.ts +52 -0
- package/dist/lib/sdk-config-adapter.d.ts.map +1 -1
- package/dist/lib/sdk-config-adapter.js +167 -6
- package/dist/types/config.d.ts +12 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/utils/github-manifest.d.ts +39 -0
- package/dist/utils/github-manifest.d.ts.map +1 -0
- package/dist/utils/github-manifest.js +84 -0
- package/package.json +5 -2
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App Authentication Module
|
|
3
|
+
*
|
|
4
|
+
* Provides JWT generation, installation token exchange, and token caching
|
|
5
|
+
* for GitHub App authentication in FABER CLI.
|
|
6
|
+
*/
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
/**
|
|
12
|
+
* Private Key Loader
|
|
13
|
+
*
|
|
14
|
+
* Loads private keys from file path or environment variable
|
|
15
|
+
*/
|
|
16
|
+
export class PrivateKeyLoader {
|
|
17
|
+
/**
|
|
18
|
+
* Load private key from configured sources.
|
|
19
|
+
* Priority: env var > file path
|
|
20
|
+
*
|
|
21
|
+
* @param config - GitHub App configuration
|
|
22
|
+
* @returns The private key content
|
|
23
|
+
* @throws Error if private key cannot be loaded
|
|
24
|
+
*/
|
|
25
|
+
static async load(config) {
|
|
26
|
+
// Try environment variable first (priority)
|
|
27
|
+
if (config.private_key_env_var) {
|
|
28
|
+
const envValue = process.env[config.private_key_env_var];
|
|
29
|
+
if (envValue) {
|
|
30
|
+
try {
|
|
31
|
+
// Decode base64-encoded key
|
|
32
|
+
const decoded = Buffer.from(envValue, 'base64').toString('utf-8');
|
|
33
|
+
if (PrivateKeyLoader.validate(decoded)) {
|
|
34
|
+
return decoded;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Invalid base64, fall through to file path
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Try file path
|
|
43
|
+
if (config.private_key_path) {
|
|
44
|
+
try {
|
|
45
|
+
// Expand ~ to home directory
|
|
46
|
+
const expandedPath = config.private_key_path.startsWith('~')
|
|
47
|
+
? config.private_key_path.replace('~', os.homedir())
|
|
48
|
+
: config.private_key_path;
|
|
49
|
+
const resolvedPath = path.resolve(expandedPath);
|
|
50
|
+
const key = await fs.readFile(resolvedPath, 'utf-8');
|
|
51
|
+
if (PrivateKeyLoader.validate(key)) {
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
throw new Error('Invalid private key format. Expected PEM-encoded RSA private key');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof Error && error.message.includes('ENOENT')) {
|
|
58
|
+
throw new Error(`GitHub App private key not found at '${config.private_key_path}'. ` +
|
|
59
|
+
`Check 'private_key_path' in config or set ${config.private_key_env_var || 'GITHUB_APP_PRIVATE_KEY'} env var`);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error('GitHub App private key not found. ' +
|
|
65
|
+
"Configure 'private_key_path' in .fractary/settings.json or set GITHUB_APP_PRIVATE_KEY env var");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate private key format.
|
|
69
|
+
*
|
|
70
|
+
* @param key - The private key content
|
|
71
|
+
* @returns true if valid PEM format
|
|
72
|
+
*/
|
|
73
|
+
static validate(key) {
|
|
74
|
+
// Check for PEM format (RSA or PKCS#8)
|
|
75
|
+
const trimmed = key.trim();
|
|
76
|
+
return ((trimmed.startsWith('-----BEGIN RSA PRIVATE KEY-----') &&
|
|
77
|
+
trimmed.endsWith('-----END RSA PRIVATE KEY-----')) ||
|
|
78
|
+
(trimmed.startsWith('-----BEGIN PRIVATE KEY-----') &&
|
|
79
|
+
trimmed.endsWith('-----END PRIVATE KEY-----')));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* GitHub App Authentication
|
|
84
|
+
*
|
|
85
|
+
* Handles JWT generation, installation token exchange, and caching
|
|
86
|
+
*/
|
|
87
|
+
export class GitHubAppAuth {
|
|
88
|
+
constructor(config) {
|
|
89
|
+
this.cache = new Map();
|
|
90
|
+
this.refreshPromise = null;
|
|
91
|
+
this.config = config;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get a valid installation token.
|
|
95
|
+
* Returns cached token if still valid, otherwise generates new one.
|
|
96
|
+
*
|
|
97
|
+
* @returns Installation access token
|
|
98
|
+
*/
|
|
99
|
+
async getToken() {
|
|
100
|
+
const cacheKey = this.config.installation_id;
|
|
101
|
+
const cached = this.cache.get(cacheKey);
|
|
102
|
+
if (cached && !this.isExpired(cached) && !this.isExpiringSoon(cached)) {
|
|
103
|
+
return cached.token;
|
|
104
|
+
}
|
|
105
|
+
// If token is expiring soon but still valid, trigger background refresh
|
|
106
|
+
if (cached && !this.isExpired(cached) && this.isExpiringSoon(cached)) {
|
|
107
|
+
this.triggerBackgroundRefresh();
|
|
108
|
+
return cached.token;
|
|
109
|
+
}
|
|
110
|
+
// Token expired or missing, must refresh synchronously
|
|
111
|
+
return this.refreshToken();
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Force refresh the token.
|
|
115
|
+
*
|
|
116
|
+
* @returns New installation access token
|
|
117
|
+
*/
|
|
118
|
+
async refreshToken() {
|
|
119
|
+
// Deduplicate concurrent refresh requests
|
|
120
|
+
if (this.refreshPromise) {
|
|
121
|
+
return this.refreshPromise;
|
|
122
|
+
}
|
|
123
|
+
this.refreshPromise = this.doRefresh();
|
|
124
|
+
try {
|
|
125
|
+
return await this.refreshPromise;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
this.refreshPromise = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Check if token needs refresh (within 5 minutes of expiration).
|
|
133
|
+
*
|
|
134
|
+
* @returns true if token should be refreshed
|
|
135
|
+
*/
|
|
136
|
+
isTokenExpiringSoon() {
|
|
137
|
+
const cached = this.cache.get(this.config.installation_id);
|
|
138
|
+
return cached ? this.isExpiringSoon(cached) : true;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Validate the configuration and private key.
|
|
142
|
+
*
|
|
143
|
+
* @throws Error if configuration is invalid
|
|
144
|
+
*/
|
|
145
|
+
async validate() {
|
|
146
|
+
// Validate required fields
|
|
147
|
+
if (!this.config.id) {
|
|
148
|
+
throw new Error("GitHub App ID is required. Configure 'app.id' in .fractary/settings.json");
|
|
149
|
+
}
|
|
150
|
+
if (!this.config.installation_id) {
|
|
151
|
+
throw new Error("GitHub App Installation ID is required. Configure 'app.installation_id' in .fractary/settings.json");
|
|
152
|
+
}
|
|
153
|
+
// Validate private key can be loaded
|
|
154
|
+
await PrivateKeyLoader.load(this.config);
|
|
155
|
+
// Attempt to generate JWT to validate key
|
|
156
|
+
await this.generateJWT();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Perform the actual token refresh
|
|
160
|
+
*/
|
|
161
|
+
async doRefresh() {
|
|
162
|
+
const jwtToken = await this.generateJWT();
|
|
163
|
+
const installationToken = await this.exchangeForInstallationToken(jwtToken);
|
|
164
|
+
// Cache the token
|
|
165
|
+
this.cache.set(this.config.installation_id, {
|
|
166
|
+
token: installationToken.token,
|
|
167
|
+
expires_at: new Date(installationToken.expires_at),
|
|
168
|
+
installation_id: this.config.installation_id,
|
|
169
|
+
});
|
|
170
|
+
return installationToken.token;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generate a JWT for GitHub App authentication
|
|
174
|
+
*/
|
|
175
|
+
async generateJWT() {
|
|
176
|
+
const privateKey = await PrivateKeyLoader.load(this.config);
|
|
177
|
+
const now = Math.floor(Date.now() / 1000);
|
|
178
|
+
const payload = {
|
|
179
|
+
iat: now - 60, // Issued 60 seconds ago to allow for clock drift
|
|
180
|
+
exp: now + GitHubAppAuth.JWT_EXPIRY_SECONDS,
|
|
181
|
+
iss: this.config.id,
|
|
182
|
+
};
|
|
183
|
+
try {
|
|
184
|
+
return jwt.sign(payload, privateKey, { algorithm: 'RS256' });
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error instanceof Error) {
|
|
188
|
+
throw new Error(`Failed to generate JWT: ${error.message}`);
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Exchange JWT for installation access token
|
|
195
|
+
*/
|
|
196
|
+
async exchangeForInstallationToken(jwtToken) {
|
|
197
|
+
const url = `${GitHubAppAuth.GITHUB_API_URL}/app/installations/${this.config.installation_id}/access_tokens`;
|
|
198
|
+
let response;
|
|
199
|
+
try {
|
|
200
|
+
response = await fetch(url, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
Accept: 'application/vnd.github+json',
|
|
204
|
+
Authorization: `Bearer ${jwtToken}`,
|
|
205
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error instanceof Error) {
|
|
211
|
+
throw new Error(`Failed to connect to GitHub API: ${error.message}`);
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
const errorBody = await response.text().catch(() => 'Unknown error');
|
|
217
|
+
if (response.status === 401) {
|
|
218
|
+
throw new Error('Failed to authenticate with GitHub App. Verify App ID and private key are correct.');
|
|
219
|
+
}
|
|
220
|
+
if (response.status === 404) {
|
|
221
|
+
throw new Error(`GitHub App installation not found (ID: ${this.config.installation_id}). ` +
|
|
222
|
+
'Verify the Installation ID is correct and the app is installed.');
|
|
223
|
+
}
|
|
224
|
+
if (response.status === 403) {
|
|
225
|
+
// Check for rate limiting
|
|
226
|
+
const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
|
|
227
|
+
const rateLimitReset = response.headers.get('x-ratelimit-reset');
|
|
228
|
+
if (rateLimitRemaining === '0' && rateLimitReset) {
|
|
229
|
+
const resetTime = new Date(parseInt(rateLimitReset) * 1000);
|
|
230
|
+
const secondsUntilReset = Math.ceil((resetTime.getTime() - Date.now()) / 1000);
|
|
231
|
+
throw new Error(`GitHub API rate limited. Retry after ${secondsUntilReset} seconds.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw new Error(`Failed to get installation token: ${response.status} ${errorBody}`);
|
|
235
|
+
}
|
|
236
|
+
return response.json();
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Check if token is expired
|
|
240
|
+
*/
|
|
241
|
+
isExpired(cached) {
|
|
242
|
+
return cached.expires_at.getTime() <= Date.now();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Check if token is expiring soon
|
|
246
|
+
*/
|
|
247
|
+
isExpiringSoon(cached) {
|
|
248
|
+
return cached.expires_at.getTime() - Date.now() < GitHubAppAuth.REFRESH_THRESHOLD_MS;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Trigger background token refresh (non-blocking)
|
|
252
|
+
*/
|
|
253
|
+
triggerBackgroundRefresh() {
|
|
254
|
+
if (this.refreshPromise) {
|
|
255
|
+
return; // Already refreshing
|
|
256
|
+
}
|
|
257
|
+
// Fire and forget - errors logged but not thrown
|
|
258
|
+
this.refreshToken().catch(error => {
|
|
259
|
+
console.error('[GitHubAppAuth] Background token refresh failed:', error.message);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Token refresh threshold (5 minutes before expiration)
|
|
264
|
+
GitHubAppAuth.REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
|
|
265
|
+
// JWT validity period (10 minutes max for GitHub)
|
|
266
|
+
GitHubAppAuth.JWT_EXPIRY_SECONDS = 600;
|
|
267
|
+
// GitHub API base URL
|
|
268
|
+
GitHubAppAuth.GITHUB_API_URL = 'https://api.github.com';
|
|
269
|
+
/**
|
|
270
|
+
* Static Token Provider
|
|
271
|
+
*
|
|
272
|
+
* Simple provider for static PAT tokens
|
|
273
|
+
*/
|
|
274
|
+
export class StaticTokenProvider {
|
|
275
|
+
constructor(token) {
|
|
276
|
+
this.token = token;
|
|
277
|
+
}
|
|
278
|
+
async getToken() {
|
|
279
|
+
return this.token;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* GitHub App Token Provider
|
|
284
|
+
*
|
|
285
|
+
* Provider that uses GitHubAppAuth for dynamic token generation
|
|
286
|
+
*/
|
|
287
|
+
export class GitHubAppTokenProvider {
|
|
288
|
+
constructor(auth) {
|
|
289
|
+
this.auth = auth;
|
|
290
|
+
}
|
|
291
|
+
async getToken() {
|
|
292
|
+
return this.auth.getToken();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App Manifest Flow for Automated Setup
|
|
3
|
+
*
|
|
4
|
+
* Provides functions for creating GitHub Apps via the App Manifest flow,
|
|
5
|
+
* which simplifies setup from 15+ manual steps to a single CLI command.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for generating an app manifest
|
|
9
|
+
*/
|
|
10
|
+
export interface ManifestConfig {
|
|
11
|
+
organization: string;
|
|
12
|
+
repository: string;
|
|
13
|
+
appName?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* GitHub App Manifest structure
|
|
17
|
+
* @see https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest
|
|
18
|
+
*/
|
|
19
|
+
export interface GitHubAppManifest {
|
|
20
|
+
name: string;
|
|
21
|
+
url: string;
|
|
22
|
+
hook_attributes: {
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
redirect_url?: string;
|
|
26
|
+
callback_urls?: string[];
|
|
27
|
+
setup_url?: string;
|
|
28
|
+
description: string;
|
|
29
|
+
public: boolean;
|
|
30
|
+
default_permissions: {
|
|
31
|
+
contents: 'read' | 'write';
|
|
32
|
+
issues: 'read' | 'write';
|
|
33
|
+
pull_requests: 'read' | 'write';
|
|
34
|
+
metadata: 'read';
|
|
35
|
+
};
|
|
36
|
+
default_events: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Response from GitHub App Manifest conversion API
|
|
40
|
+
* @see https://docs.github.com/en/rest/apps/apps#create-a-github-app-from-a-manifest
|
|
41
|
+
*/
|
|
42
|
+
export interface ManifestConversionResponse {
|
|
43
|
+
id: number;
|
|
44
|
+
slug: string;
|
|
45
|
+
node_id: string;
|
|
46
|
+
owner: {
|
|
47
|
+
login: string;
|
|
48
|
+
id: number;
|
|
49
|
+
};
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
external_url: string;
|
|
53
|
+
html_url: string;
|
|
54
|
+
created_at: string;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
permissions: {
|
|
57
|
+
contents?: string;
|
|
58
|
+
issues?: string;
|
|
59
|
+
metadata?: string;
|
|
60
|
+
pull_requests?: string;
|
|
61
|
+
};
|
|
62
|
+
events: string[];
|
|
63
|
+
installations_count: number;
|
|
64
|
+
client_id: string;
|
|
65
|
+
client_secret: string;
|
|
66
|
+
webhook_secret: string | null;
|
|
67
|
+
pem: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* App credentials extracted from manifest conversion
|
|
71
|
+
*/
|
|
72
|
+
export interface AppCredentials {
|
|
73
|
+
id: string;
|
|
74
|
+
installation_id: string;
|
|
75
|
+
private_key: string;
|
|
76
|
+
app_slug: string;
|
|
77
|
+
app_name: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate GitHub App manifest with FABER's required permissions
|
|
81
|
+
*/
|
|
82
|
+
export declare function generateAppManifest(config: ManifestConfig): GitHubAppManifest;
|
|
83
|
+
/**
|
|
84
|
+
* Generate GitHub App creation URL
|
|
85
|
+
*
|
|
86
|
+
* Note: GitHub does not support pre-filled manifests via URL parameters.
|
|
87
|
+
* Users must manually fill in the form or use the manifest conversion API.
|
|
88
|
+
*/
|
|
89
|
+
export declare function getManifestCreationUrl(manifest: GitHubAppManifest): string;
|
|
90
|
+
/**
|
|
91
|
+
* Exchange manifest code for app credentials
|
|
92
|
+
*
|
|
93
|
+
* @param code - The code from the GitHub redirect URL
|
|
94
|
+
* @returns App credentials from GitHub
|
|
95
|
+
* @throws Error if code is invalid or API request fails
|
|
96
|
+
*/
|
|
97
|
+
export declare function exchangeCodeForCredentials(code: string): Promise<ManifestConversionResponse>;
|
|
98
|
+
/**
|
|
99
|
+
* Validate app credentials from manifest conversion
|
|
100
|
+
*
|
|
101
|
+
* @param response - The manifest conversion response
|
|
102
|
+
* @throws Error if response is invalid
|
|
103
|
+
*/
|
|
104
|
+
export declare function validateAppCredentials(response: ManifestConversionResponse): void;
|
|
105
|
+
/**
|
|
106
|
+
* Fetch installation ID for the app in the specified organization
|
|
107
|
+
*
|
|
108
|
+
* @param appId - The GitHub App ID
|
|
109
|
+
* @param privateKey - The app's private key in PEM format
|
|
110
|
+
* @param organization - The GitHub organization name
|
|
111
|
+
* @returns The installation ID
|
|
112
|
+
* @throws Error if installation not found or authentication fails
|
|
113
|
+
*/
|
|
114
|
+
export declare function getInstallationId(appId: string, privateKey: string, organization: string): Promise<string>;
|
|
115
|
+
/**
|
|
116
|
+
* Save private key to secure location
|
|
117
|
+
*
|
|
118
|
+
* @param privateKey - The private key content
|
|
119
|
+
* @param organization - The organization name (for filename)
|
|
120
|
+
* @returns Path to the saved private key file
|
|
121
|
+
* @throws Error if file cannot be saved
|
|
122
|
+
*/
|
|
123
|
+
export declare function savePrivateKey(privateKey: string, organization: string): Promise<string>;
|
|
124
|
+
/**
|
|
125
|
+
* Format permissions for display
|
|
126
|
+
*
|
|
127
|
+
* @param manifest - The app manifest
|
|
128
|
+
* @returns Formatted permissions string
|
|
129
|
+
*/
|
|
130
|
+
export declare function formatPermissionsDisplay(manifest: GitHubAppManifest): string;
|
|
131
|
+
//# sourceMappingURL=github-app-setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github-app-setup.d.ts","sourceRoot":"","sources":["../../src/lib/github-app-setup.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE;QACf,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,mBAAmB,EAAE;QACnB,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAC3B,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QACzB,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,cAAc,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE;QACL,KAAK,EAAE,MAAM,CAAC;QACd,EAAE,EAAE,MAAM,CAAC;KACZ,CAAC;IACF,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE;QACX,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;IACF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAqBD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,GAAG,iBAAiB,CAoB7E;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,CAG1E;AAED;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,0BAA0B,CAAC,CAuCrC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,0BAA0B,GAAG,IAAI,CAoBjF;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,CAiDjB;AA2BD;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,CA4CjB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,CAY5E"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App Manifest Flow for Automated Setup
|
|
3
|
+
*
|
|
4
|
+
* Provides functions for creating GitHub Apps via the App Manifest flow,
|
|
5
|
+
* which simplifies setup from 15+ manual steps to a single CLI command.
|
|
6
|
+
*/
|
|
7
|
+
import jwt from 'jsonwebtoken';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
/**
|
|
12
|
+
* Generate GitHub App manifest with FABER's required permissions
|
|
13
|
+
*/
|
|
14
|
+
export function generateAppManifest(config) {
|
|
15
|
+
const appName = config.appName || `FABER CLI - ${config.organization}`;
|
|
16
|
+
return {
|
|
17
|
+
name: appName,
|
|
18
|
+
url: 'https://github.com/fractary/faber',
|
|
19
|
+
hook_attributes: {
|
|
20
|
+
url: 'https://example.com/webhook', // Required but not used
|
|
21
|
+
},
|
|
22
|
+
description: 'FABER CLI for automated workflow management, issue tracking, and repository operations.',
|
|
23
|
+
public: false,
|
|
24
|
+
default_permissions: {
|
|
25
|
+
contents: 'write',
|
|
26
|
+
issues: 'write',
|
|
27
|
+
pull_requests: 'write',
|
|
28
|
+
metadata: 'read',
|
|
29
|
+
},
|
|
30
|
+
default_events: [], // No webhook events needed
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generate GitHub App creation URL
|
|
35
|
+
*
|
|
36
|
+
* Note: GitHub does not support pre-filled manifests via URL parameters.
|
|
37
|
+
* Users must manually fill in the form or use the manifest conversion API.
|
|
38
|
+
*/
|
|
39
|
+
export function getManifestCreationUrl(manifest) {
|
|
40
|
+
// GitHub App creation page
|
|
41
|
+
return 'https://github.com/settings/apps/new';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Exchange manifest code for app credentials
|
|
45
|
+
*
|
|
46
|
+
* @param code - The code from the GitHub redirect URL
|
|
47
|
+
* @returns App credentials from GitHub
|
|
48
|
+
* @throws Error if code is invalid or API request fails
|
|
49
|
+
*/
|
|
50
|
+
export async function exchangeCodeForCredentials(code) {
|
|
51
|
+
const url = `https://api.github.com/app-manifests/${code}/conversions`;
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
Accept: 'application/vnd.github+json',
|
|
58
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
throw new Error(`Failed to connect to GitHub API: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const errorBody = await response.text().catch(() => 'Unknown error');
|
|
70
|
+
if (response.status === 404) {
|
|
71
|
+
throw new Error('Invalid or expired code. The code may have already been used or is not valid. ' +
|
|
72
|
+
'Please run "fractary-faber auth setup" again to generate a new URL.');
|
|
73
|
+
}
|
|
74
|
+
if (response.status === 422) {
|
|
75
|
+
throw new Error('Invalid code format. The code should be from the GitHub redirect URL after creating the app.');
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Failed to exchange code for credentials: ${response.status} ${errorBody}`);
|
|
78
|
+
}
|
|
79
|
+
return (await response.json());
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Validate app credentials from manifest conversion
|
|
83
|
+
*
|
|
84
|
+
* @param response - The manifest conversion response
|
|
85
|
+
* @throws Error if response is invalid
|
|
86
|
+
*/
|
|
87
|
+
export function validateAppCredentials(response) {
|
|
88
|
+
if (!response.id) {
|
|
89
|
+
throw new Error('Invalid response: missing app ID');
|
|
90
|
+
}
|
|
91
|
+
if (!response.pem) {
|
|
92
|
+
throw new Error('Invalid response: missing private key');
|
|
93
|
+
}
|
|
94
|
+
// Validate PEM format
|
|
95
|
+
const trimmed = response.pem.trim();
|
|
96
|
+
const isValidPEM = (trimmed.startsWith('-----BEGIN RSA PRIVATE KEY-----') &&
|
|
97
|
+
trimmed.endsWith('-----END RSA PRIVATE KEY-----')) ||
|
|
98
|
+
(trimmed.startsWith('-----BEGIN PRIVATE KEY-----') &&
|
|
99
|
+
trimmed.endsWith('-----END PRIVATE KEY-----'));
|
|
100
|
+
if (!isValidPEM) {
|
|
101
|
+
throw new Error('Invalid response: private key is not in PEM format');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fetch installation ID for the app in the specified organization
|
|
106
|
+
*
|
|
107
|
+
* @param appId - The GitHub App ID
|
|
108
|
+
* @param privateKey - The app's private key in PEM format
|
|
109
|
+
* @param organization - The GitHub organization name
|
|
110
|
+
* @returns The installation ID
|
|
111
|
+
* @throws Error if installation not found or authentication fails
|
|
112
|
+
*/
|
|
113
|
+
export async function getInstallationId(appId, privateKey, organization) {
|
|
114
|
+
// Generate JWT for app authentication
|
|
115
|
+
const jwtToken = generateJWT(appId, privateKey);
|
|
116
|
+
// Fetch installation for organization
|
|
117
|
+
const url = `https://api.github.com/orgs/${organization}/installation`;
|
|
118
|
+
let response;
|
|
119
|
+
try {
|
|
120
|
+
response = await fetch(url, {
|
|
121
|
+
method: 'GET',
|
|
122
|
+
headers: {
|
|
123
|
+
Accept: 'application/vnd.github+json',
|
|
124
|
+
Authorization: `Bearer ${jwtToken}`,
|
|
125
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof Error) {
|
|
131
|
+
throw new Error(`Failed to connect to GitHub API: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const errorBody = await response.text().catch(() => 'Unknown error');
|
|
137
|
+
if (response.status === 404) {
|
|
138
|
+
throw new Error(`GitHub App not installed on organization "${organization}". ` +
|
|
139
|
+
`Please install the app on at least one repository in the organization. ` +
|
|
140
|
+
`Visit the app settings to install it.`);
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 401) {
|
|
143
|
+
throw new Error('Failed to authenticate with GitHub App. This should not happen with a newly created app. ' +
|
|
144
|
+
'Please try running the setup command again.');
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Failed to fetch installation ID: ${response.status} ${errorBody}`);
|
|
147
|
+
}
|
|
148
|
+
const installation = (await response.json());
|
|
149
|
+
return installation.id.toString();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Generate a JWT for GitHub App authentication
|
|
153
|
+
*
|
|
154
|
+
* @param appId - The GitHub App ID
|
|
155
|
+
* @param privateKey - The app's private key in PEM format
|
|
156
|
+
* @returns JWT token
|
|
157
|
+
*/
|
|
158
|
+
function generateJWT(appId, privateKey) {
|
|
159
|
+
const now = Math.floor(Date.now() / 1000);
|
|
160
|
+
const payload = {
|
|
161
|
+
iat: now - 60, // Issued 60 seconds ago to allow for clock drift
|
|
162
|
+
exp: now + 600, // Expires in 10 minutes (GitHub max)
|
|
163
|
+
iss: appId,
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
return jwt.sign(payload, privateKey, { algorithm: 'RS256' });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof Error) {
|
|
170
|
+
throw new Error(`Failed to generate JWT: ${error.message}`);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Save private key to secure location
|
|
177
|
+
*
|
|
178
|
+
* @param privateKey - The private key content
|
|
179
|
+
* @param organization - The organization name (for filename)
|
|
180
|
+
* @returns Path to the saved private key file
|
|
181
|
+
* @throws Error if file cannot be saved
|
|
182
|
+
*/
|
|
183
|
+
export async function savePrivateKey(privateKey, organization) {
|
|
184
|
+
const homeDir = os.homedir();
|
|
185
|
+
const githubDir = path.join(homeDir, '.github');
|
|
186
|
+
// Ensure .github directory exists with restricted permissions
|
|
187
|
+
try {
|
|
188
|
+
await fs.mkdir(githubDir, { recursive: true, mode: 0o700 });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error instanceof Error) {
|
|
192
|
+
throw new Error(`Failed to create .github directory: ${error.message}`);
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
// Generate key filename
|
|
197
|
+
const keyFileName = `faber-${organization}.pem`;
|
|
198
|
+
const keyPath = path.join(githubDir, keyFileName);
|
|
199
|
+
// Save private key with restricted permissions
|
|
200
|
+
try {
|
|
201
|
+
await fs.writeFile(keyPath, privateKey, { mode: 0o600 });
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof Error) {
|
|
205
|
+
throw new Error(`Failed to write private key file: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
// Verify file was created with correct permissions (Unix only)
|
|
210
|
+
try {
|
|
211
|
+
const stats = await fs.stat(keyPath);
|
|
212
|
+
const mode = stats.mode & 0o777;
|
|
213
|
+
if (process.platform !== 'win32' && mode !== 0o600) {
|
|
214
|
+
console.warn(`Warning: Private key file permissions are ${mode.toString(8)}, ` +
|
|
215
|
+
`expected 0600. Please restrict access with: chmod 600 ${keyPath}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Ignore stat errors
|
|
220
|
+
}
|
|
221
|
+
return keyPath;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Format permissions for display
|
|
225
|
+
*
|
|
226
|
+
* @param manifest - The app manifest
|
|
227
|
+
* @returns Formatted permissions string
|
|
228
|
+
*/
|
|
229
|
+
export function formatPermissionsDisplay(manifest) {
|
|
230
|
+
const perms = manifest.default_permissions;
|
|
231
|
+
return Object.entries(perms)
|
|
232
|
+
.map(([key, value]) => {
|
|
233
|
+
const name = key
|
|
234
|
+
.split('_')
|
|
235
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
236
|
+
.join(' ');
|
|
237
|
+
const level = value.charAt(0).toUpperCase() + value.slice(1);
|
|
238
|
+
return ` • ${name}: ${level}`;
|
|
239
|
+
})
|
|
240
|
+
.join('\n');
|
|
241
|
+
}
|