@fractary/faber-cli 1.3.2 → 1.3.4

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.
@@ -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,142 @@
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 HTML content for manifest submission
85
+ *
86
+ * The GitHub App Manifest flow requires POSTing a form to GitHub.
87
+ * Since CLI cannot POST directly, we generate an HTML file that the user
88
+ * opens in their browser and clicks to submit.
89
+ *
90
+ * @param manifest - The app manifest to submit
91
+ * @param organization - GitHub organization name
92
+ * @returns HTML content ready to save to file
93
+ */
94
+ export declare function generateManifestHtml(manifest: GitHubAppManifest, organization: string): string;
95
+ /**
96
+ * Generate GitHub App creation URL (legacy - prefer generateManifestHtml)
97
+ *
98
+ * @deprecated Use generateManifestHtml() instead for proper manifest flow
99
+ */
100
+ export declare function getManifestCreationUrl(manifest: GitHubAppManifest): string;
101
+ /**
102
+ * Exchange manifest code for app credentials
103
+ *
104
+ * @param code - The code from the GitHub redirect URL
105
+ * @returns App credentials from GitHub
106
+ * @throws Error if code is invalid or API request fails
107
+ */
108
+ export declare function exchangeCodeForCredentials(code: string): Promise<ManifestConversionResponse>;
109
+ /**
110
+ * Validate app credentials from manifest conversion
111
+ *
112
+ * @param response - The manifest conversion response
113
+ * @throws Error if response is invalid
114
+ */
115
+ export declare function validateAppCredentials(response: ManifestConversionResponse): void;
116
+ /**
117
+ * Fetch installation ID for the app in the specified organization
118
+ *
119
+ * @param appId - The GitHub App ID
120
+ * @param privateKey - The app's private key in PEM format
121
+ * @param organization - The GitHub organization name
122
+ * @returns The installation ID
123
+ * @throws Error if installation not found or authentication fails
124
+ */
125
+ export declare function getInstallationId(appId: string, privateKey: string, organization: string): Promise<string>;
126
+ /**
127
+ * Save private key to secure location
128
+ *
129
+ * @param privateKey - The private key content
130
+ * @param organization - The organization name (for filename)
131
+ * @returns Path to the saved private key file
132
+ * @throws Error if file cannot be saved
133
+ */
134
+ export declare function savePrivateKey(privateKey: string, organization: string): Promise<string>;
135
+ /**
136
+ * Format permissions for display
137
+ *
138
+ * @param manifest - The app manifest
139
+ * @returns Formatted permissions string
140
+ */
141
+ export declare function formatPermissionsDisplay(manifest: GitHubAppManifest): string;
142
+ //# 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":"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"}