@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.
@@ -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":"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"}
@@ -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
+ }