@aifabrix/builder 2.1.7 → 2.3.0

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.
Files changed (43) hide show
  1. package/lib/app-deploy.js +73 -29
  2. package/lib/app-list.js +132 -0
  3. package/lib/app-readme.js +11 -4
  4. package/lib/app-register.js +435 -0
  5. package/lib/app-rotate-secret.js +164 -0
  6. package/lib/app-run.js +98 -84
  7. package/lib/app.js +13 -0
  8. package/lib/audit-logger.js +195 -15
  9. package/lib/build.js +155 -42
  10. package/lib/cli.js +104 -8
  11. package/lib/commands/app.js +8 -391
  12. package/lib/commands/login.js +130 -36
  13. package/lib/commands/secure.js +260 -0
  14. package/lib/config.js +315 -4
  15. package/lib/deployer.js +221 -183
  16. package/lib/infra.js +177 -112
  17. package/lib/push.js +34 -7
  18. package/lib/secrets.js +89 -23
  19. package/lib/templates.js +1 -1
  20. package/lib/utils/api-error-handler.js +465 -0
  21. package/lib/utils/api.js +165 -16
  22. package/lib/utils/auth-headers.js +84 -0
  23. package/lib/utils/build-copy.js +162 -0
  24. package/lib/utils/cli-utils.js +49 -3
  25. package/lib/utils/compose-generator.js +57 -16
  26. package/lib/utils/deployment-errors.js +90 -0
  27. package/lib/utils/deployment-validation.js +60 -0
  28. package/lib/utils/dev-config.js +83 -0
  29. package/lib/utils/docker-build.js +24 -0
  30. package/lib/utils/env-template.js +30 -10
  31. package/lib/utils/health-check.js +18 -1
  32. package/lib/utils/infra-containers.js +101 -0
  33. package/lib/utils/local-secrets.js +0 -2
  34. package/lib/utils/secrets-encryption.js +203 -0
  35. package/lib/utils/secrets-path.js +22 -3
  36. package/lib/utils/token-manager.js +381 -0
  37. package/package.json +2 -2
  38. package/templates/applications/README.md.hbs +155 -23
  39. package/templates/applications/miso-controller/Dockerfile +7 -119
  40. package/templates/infra/compose.yaml.hbs +93 -0
  41. package/templates/python/docker-compose.hbs +25 -17
  42. package/templates/typescript/docker-compose.hbs +25 -17
  43. package/test-output.txt +0 -5431
package/lib/secrets.js CHANGED
@@ -15,6 +15,8 @@ const yaml = require('js-yaml');
15
15
  const os = require('os');
16
16
  const chalk = require('chalk');
17
17
  const logger = require('./utils/logger');
18
+ const config = require('./config');
19
+ const devConfig = require('./utils/dev-config');
18
20
  const {
19
21
  generateMissingSecrets,
20
22
  createDefaultSecrets
@@ -30,6 +32,7 @@ const {
30
32
  buildHostnameToServiceMap,
31
33
  resolveUrlPort
32
34
  } = require('./utils/secrets-utils');
35
+ const { decryptSecret, isEncrypted } = require('./utils/secrets-encryption');
33
36
 
34
37
  /**
35
38
  * Loads environment configuration for docker/local context
@@ -41,16 +44,59 @@ function loadEnvConfig() {
41
44
  return yaml.load(content);
42
45
  }
43
46
 
47
+ /**
48
+ * Decrypts encrypted values in secrets object
49
+ * Checks for secure:// prefix and decrypts using encryption key from config
50
+ *
51
+ * @async
52
+ * @function decryptSecretsObject
53
+ * @param {Object} secrets - Secrets object with potentially encrypted values
54
+ * @returns {Promise<Object>} Secrets object with decrypted values
55
+ * @throws {Error} If decryption fails or encryption key is missing
56
+ */
57
+ async function decryptSecretsObject(secrets) {
58
+ if (!secrets || typeof secrets !== 'object') {
59
+ return secrets;
60
+ }
61
+
62
+ const encryptionKey = await config.getSecretsEncryptionKey();
63
+ if (!encryptionKey) {
64
+ // No encryption key set, check if any values are encrypted
65
+ const hasEncrypted = Object.values(secrets).some(value => isEncrypted(value));
66
+ if (hasEncrypted) {
67
+ throw new Error('Encrypted secrets found but no encryption key configured. Run "aifabrix secure --secrets-encryption <key>" to set encryption key.');
68
+ }
69
+ // No encrypted values, return as-is
70
+ return secrets;
71
+ }
72
+
73
+ const decryptedSecrets = {};
74
+ for (const [key, value] of Object.entries(secrets)) {
75
+ if (isEncrypted(value)) {
76
+ try {
77
+ decryptedSecrets[key] = decryptSecret(value, encryptionKey);
78
+ } catch (error) {
79
+ throw new Error(`Failed to decrypt secret '${key}': ${error.message}`);
80
+ }
81
+ } else {
82
+ decryptedSecrets[key] = value;
83
+ }
84
+ }
85
+
86
+ return decryptedSecrets;
87
+ }
88
+
44
89
  /**
45
90
  * Loads secrets with cascading lookup
46
91
  * Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
47
92
  * User's file takes priority, then falls back to build.secrets from variables.yaml
93
+ * Automatically decrypts values with secure:// prefix
48
94
  *
49
95
  * @async
50
96
  * @function loadSecrets
51
97
  * @param {string} [secretsPath] - Path to secrets file (optional, for explicit override)
52
98
  * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
53
- * @returns {Promise<Object>} Loaded secrets object
99
+ * @returns {Promise<Object>} Loaded secrets object with decrypted values
54
100
  * @throws {Error} If no secrets file found and no fallback available
55
101
  *
56
102
  * @example
@@ -58,6 +104,8 @@ function loadEnvConfig() {
58
104
  * // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
59
105
  */
60
106
  async function loadSecrets(secretsPath, appName) {
107
+ let secrets;
108
+
61
109
  // If explicit path provided, use it (backward compatibility)
62
110
  if (secretsPath) {
63
111
  const resolvedPath = resolveSecretsPath(secretsPath);
@@ -66,34 +114,35 @@ async function loadSecrets(secretsPath, appName) {
66
114
  }
67
115
 
68
116
  const content = fs.readFileSync(resolvedPath, 'utf8');
69
- const secrets = yaml.load(content);
117
+ secrets = yaml.load(content);
70
118
 
71
119
  if (!secrets || typeof secrets !== 'object') {
72
120
  throw new Error(`Invalid secrets file format: ${resolvedPath}`);
73
121
  }
122
+ } else {
123
+ // Cascading lookup: user's file first
124
+ let mergedSecrets = loadUserSecrets();
74
125
 
75
- return secrets;
76
- }
77
-
78
- // Cascading lookup: user's file first
79
- let mergedSecrets = loadUserSecrets();
126
+ // Then check build.secrets from variables.yaml if appName provided
127
+ if (appName) {
128
+ mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
129
+ }
80
130
 
81
- // Then check build.secrets from variables.yaml if appName provided
82
- if (appName) {
83
- mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
84
- }
131
+ // If still no secrets found, try default location
132
+ if (Object.keys(mergedSecrets).length === 0) {
133
+ mergedSecrets = loadDefaultSecrets();
134
+ }
85
135
 
86
- // If still no secrets found, try default location
87
- if (Object.keys(mergedSecrets).length === 0) {
88
- mergedSecrets = loadDefaultSecrets();
89
- }
136
+ // If still empty, throw error
137
+ if (Object.keys(mergedSecrets).length === 0) {
138
+ throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
139
+ }
90
140
 
91
- // If still empty, throw error
92
- if (Object.keys(mergedSecrets).length === 0) {
93
- throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
141
+ secrets = mergedSecrets;
94
142
  }
95
143
 
96
- return mergedSecrets;
144
+ // Decrypt encrypted values
145
+ return await decryptSecretsObject(secrets);
97
146
  }
98
147
 
99
148
  /**
@@ -108,6 +157,7 @@ async function loadSecrets(secretsPath, appName) {
108
157
  * @param {Object|string|null} [secretsFilePaths] - Paths object with userPath and buildPath, or string path (for backward compatibility)
109
158
  * @param {string} [secretsFilePaths.userPath] - User's secrets file path
110
159
  * @param {string|null} [secretsFilePaths.buildPath] - App's build.secrets file path (if configured)
160
+ * @param {string} [appName] - Application name (optional, for error messages)
111
161
  * @returns {Promise<string>} Resolved environment content
112
162
  * @throws {Error} If kv:// reference cannot be resolved
113
163
  *
@@ -115,7 +165,7 @@ async function loadSecrets(secretsPath, appName) {
115
165
  * const resolved = await resolveKvReferences(template, secrets, 'local');
116
166
  * // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
117
167
  */
118
- async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null) {
168
+ async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null, appName = null) {
119
169
  const envConfig = loadEnvConfig();
120
170
  const envVars = envConfig.environments[environment] || envConfig.environments.local;
121
171
 
@@ -150,7 +200,8 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
150
200
  fileInfo = `\n\nSecrets file location: ${paths.join(' and ')}`;
151
201
  }
152
202
  }
153
- throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}`);
203
+ const resolveCommand = appName ? `aifabrix resolve ${appName}` : 'aifabrix resolve <app-name>';
204
+ throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}\n\nRun "${resolveCommand}" to generate missing secrets.`);
154
205
  }
155
206
 
156
207
  // Now replace kv:// references, and handle ${VAR} inside the secret values
@@ -288,7 +339,7 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
288
339
  const template = loadEnvTemplate(templatePath);
289
340
 
290
341
  // Resolve secrets paths to show in error messages (use actual paths that loadSecrets would use)
291
- const secretsPaths = getActualSecretsPath(secretsPath, appName);
342
+ const secretsPaths = await getActualSecretsPath(secretsPath, appName);
292
343
 
293
344
  if (force) {
294
345
  // Use userPath for generating missing secrets (priority file)
@@ -296,13 +347,28 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
296
347
  }
297
348
 
298
349
  const secrets = await loadSecrets(secretsPath, appName);
299
- let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths);
350
+ let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths, appName);
300
351
 
301
352
  // Resolve service ports in URLs for docker environment
302
353
  if (environment === 'docker') {
303
354
  resolved = resolveServicePortsInEnvContent(resolved, environment);
304
355
  }
305
356
 
357
+ // For local environment, update infrastructure ports to use dev-specific ports
358
+ if (environment === 'local') {
359
+ const devId = await config.getDeveloperId();
360
+ const ports = devConfig.getDevPorts(devId);
361
+
362
+ // Update DATABASE_PORT if present
363
+ resolved = resolved.replace(/^DATABASE_PORT\s*=\s*.*$/m, `DATABASE_PORT=${ports.postgres}`);
364
+
365
+ // Update REDIS_URL if present (format: redis://localhost:port)
366
+ resolved = resolved.replace(/^REDIS_URL\s*=\s*redis:\/\/localhost:\d+/m, `REDIS_URL=redis://localhost:${ports.redis}`);
367
+
368
+ // Update REDIS_HOST if it contains a port
369
+ resolved = resolved.replace(/^REDIS_HOST\s*=\s*localhost:\d+/m, `REDIS_HOST=localhost:${ports.redis}`);
370
+ }
371
+
306
372
  fs.writeFileSync(envPath, resolved, { mode: 0o600 });
307
373
 
308
374
  // Update PORT variable in container .env file to use port (from variables.yaml)
package/lib/templates.js CHANGED
@@ -49,7 +49,7 @@ function generateVariablesYaml(appName, config) {
49
49
  build: {
50
50
  language: config.language || 'typescript',
51
51
  envOutputPath: null,
52
- context: `../${appName}`,
52
+ context: null, // Defaults to dev directory in build process
53
53
  dockerfile: '',
54
54
  secrets: null
55
55
  },
@@ -0,0 +1,465 @@
1
+ /**
2
+ * AI Fabrix Builder API Error Handler
3
+ *
4
+ * Parses and formats structured error responses from the controller API
5
+ * Handles permission errors, validation errors, authentication errors,
6
+ * network errors, and server errors with user-friendly formatting
7
+ *
8
+ * @fileoverview API error handling utilities for AI Fabrix Builder
9
+ * @author AI Fabrix Team
10
+ * @version 2.0.0
11
+ */
12
+
13
+ const chalk = require('chalk');
14
+
15
+ /**
16
+ * Formats permission error with missing and required permissions
17
+ * @param {Object} errorData - Error response data
18
+ * @returns {string} Formatted permission error message
19
+ */
20
+ function formatPermissionError(errorData) {
21
+ const lines = [];
22
+ lines.push(chalk.red('❌ Permission Denied\n'));
23
+
24
+ // Handle detail message if present
25
+ if (errorData.detail) {
26
+ lines.push(chalk.yellow(errorData.detail));
27
+ lines.push('');
28
+ }
29
+
30
+ // Extract missing permissions (support both flat and nested structures)
31
+ let missingPerms = [];
32
+ if (errorData.missingPermissions && Array.isArray(errorData.missingPermissions)) {
33
+ missingPerms = errorData.missingPermissions;
34
+ } else if (errorData.missing && errorData.missing.permissions && Array.isArray(errorData.missing.permissions)) {
35
+ missingPerms = errorData.missing.permissions;
36
+ }
37
+
38
+ if (missingPerms.length > 0) {
39
+ lines.push(chalk.yellow('Missing permissions:'));
40
+ missingPerms.forEach(perm => {
41
+ lines.push(chalk.gray(` - ${perm}`));
42
+ });
43
+ lines.push('');
44
+ }
45
+
46
+ // Extract required permissions (support both flat and nested structures)
47
+ let requiredPerms = [];
48
+ if (errorData.requiredPermissions && Array.isArray(errorData.requiredPermissions)) {
49
+ requiredPerms = errorData.requiredPermissions;
50
+ } else if (errorData.required && errorData.required.permissions && Array.isArray(errorData.required.permissions)) {
51
+ requiredPerms = errorData.required.permissions;
52
+ }
53
+
54
+ if (requiredPerms.length > 0) {
55
+ lines.push(chalk.yellow('Required permissions:'));
56
+ requiredPerms.forEach(perm => {
57
+ lines.push(chalk.gray(` - ${perm}`));
58
+ });
59
+ lines.push('');
60
+ }
61
+
62
+ // Use instance (from RFC 7807) or url field
63
+ const requestUrl = errorData.instance || errorData.url;
64
+ const method = errorData.method || 'POST';
65
+ if (requestUrl) {
66
+ lines.push(chalk.gray(`Request: ${method} ${requestUrl}`));
67
+ }
68
+
69
+ if (errorData.correlationId) {
70
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
71
+ }
72
+
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /**
77
+ * Formats validation error with field-level details
78
+ * Handles unified error API response format (RFC 7807 Problem Details)
79
+ * @param {Object} errorData - Error response data
80
+ * @returns {string} Formatted validation error message
81
+ */
82
+ function formatValidationError(errorData) {
83
+ const lines = [];
84
+ lines.push(chalk.red('❌ Validation Error\n'));
85
+
86
+ // Handle RFC 7807 Problem Details format
87
+ // Priority: detail > title > message
88
+ if (errorData.detail) {
89
+ lines.push(chalk.yellow(errorData.detail));
90
+ lines.push('');
91
+ } else if (errorData.title) {
92
+ lines.push(chalk.yellow(errorData.title));
93
+ lines.push('');
94
+ } else if (errorData.message) {
95
+ lines.push(chalk.yellow(errorData.message));
96
+ lines.push('');
97
+ }
98
+
99
+ // Handle errors array - this is the most important part
100
+ if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
101
+ lines.push(chalk.yellow('Validation errors:'));
102
+ errorData.errors.forEach(err => {
103
+ const field = err.field || err.path || 'validation';
104
+ const message = err.message || 'Invalid value';
105
+ // If field is 'validation' or generic, just show the message
106
+ if (field === 'validation' || field === 'unknown') {
107
+ lines.push(chalk.gray(` • ${message}`));
108
+ } else {
109
+ lines.push(chalk.gray(` • ${field}: ${message}`));
110
+ }
111
+ });
112
+ lines.push('');
113
+ }
114
+
115
+ // Show instance (endpoint) if available (RFC 7807)
116
+ if (errorData.instance) {
117
+ lines.push(chalk.gray(`Endpoint: ${errorData.instance}`));
118
+ }
119
+
120
+ // Show correlation ID if available
121
+ if (errorData.correlationId) {
122
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
123
+ }
124
+
125
+ return lines.join('\n');
126
+ }
127
+
128
+ /**
129
+ * Formats authentication error
130
+ * @param {Object} errorData - Error response data
131
+ * @returns {string} Formatted authentication error message
132
+ */
133
+ function formatAuthenticationError(errorData) {
134
+ const lines = [];
135
+ lines.push(chalk.red('❌ Authentication Failed\n'));
136
+
137
+ if (errorData.message) {
138
+ lines.push(chalk.yellow(errorData.message));
139
+ } else {
140
+ lines.push(chalk.yellow('Invalid credentials or token expired.'));
141
+ }
142
+ lines.push('');
143
+ lines.push(chalk.gray('Run: aifabrix login'));
144
+
145
+ if (errorData.correlationId) {
146
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
147
+ }
148
+
149
+ return lines.join('\n');
150
+ }
151
+
152
+ /**
153
+ * Formats network error
154
+ * @param {string} errorMessage - Error message
155
+ * @param {Object} errorData - Error response data (optional)
156
+ * @returns {string} Formatted network error message
157
+ */
158
+ function formatNetworkError(errorMessage, errorData) {
159
+ const lines = [];
160
+ lines.push(chalk.red('❌ Network Error\n'));
161
+
162
+ if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('Cannot connect')) {
163
+ lines.push(chalk.yellow('Cannot connect to controller.'));
164
+ lines.push(chalk.gray('Check if the controller is running.'));
165
+ } else if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('hostname not found')) {
166
+ lines.push(chalk.yellow('Controller hostname not found.'));
167
+ lines.push(chalk.gray('Check your controller URL.'));
168
+ } else if (errorMessage.includes('timeout') || errorMessage.includes('timed out')) {
169
+ lines.push(chalk.yellow('Request timed out.'));
170
+ lines.push(chalk.gray('The controller may be overloaded.'));
171
+ } else {
172
+ lines.push(chalk.yellow(errorMessage));
173
+ }
174
+
175
+ if (errorData && errorData.correlationId) {
176
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
177
+ }
178
+
179
+ return lines.join('\n');
180
+ }
181
+
182
+ /**
183
+ * Formats server error (500+)
184
+ * @param {Object} errorData - Error response data
185
+ * @returns {string} Formatted server error message
186
+ */
187
+ function formatServerError(errorData) {
188
+ const lines = [];
189
+ lines.push(chalk.red('❌ Server Error\n'));
190
+
191
+ // Check for RFC 7807 Problem Details format (detail field)
192
+ if (errorData.detail) {
193
+ lines.push(chalk.yellow(errorData.detail));
194
+ } else if (errorData.message) {
195
+ lines.push(chalk.yellow(errorData.message));
196
+ } else {
197
+ lines.push(chalk.yellow('An internal server error occurred.'));
198
+ }
199
+ lines.push('');
200
+ lines.push(chalk.gray('Please try again later or contact support.'));
201
+
202
+ if (errorData.correlationId) {
203
+ lines.push('');
204
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
205
+ }
206
+
207
+ return lines.join('\n');
208
+ }
209
+
210
+ /**
211
+ * Formats conflict error (409)
212
+ * @param {Object} errorData - Error response data
213
+ * @returns {string} Formatted conflict error message
214
+ */
215
+ function formatConflictError(errorData) {
216
+ const lines = [];
217
+ lines.push(chalk.red('❌ Conflict\n'));
218
+
219
+ // Check if it's an application already exists error
220
+ const detail = errorData.detail || errorData.message || '';
221
+ if (detail.toLowerCase().includes('application already exists')) {
222
+ lines.push(chalk.yellow('This application already exists in this environment.'));
223
+ lines.push('');
224
+ lines.push(chalk.gray('Options:'));
225
+ lines.push(chalk.gray(' • Use a different environment'));
226
+ lines.push(chalk.gray(' • Check existing applications: aifabrix app list -e <environment>'));
227
+ lines.push(chalk.gray(' • Update the existing application if needed'));
228
+ } else if (detail) {
229
+ lines.push(chalk.yellow(detail));
230
+ } else if (errorData.message) {
231
+ lines.push(chalk.yellow(errorData.message));
232
+ } else {
233
+ lines.push(chalk.yellow('A conflict occurred. The resource may already exist.'));
234
+ }
235
+
236
+ if (errorData.correlationId) {
237
+ lines.push('');
238
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
239
+ }
240
+
241
+ return lines.join('\n');
242
+ }
243
+
244
+ /**
245
+ * Formats not found error (404)
246
+ * @param {Object} errorData - Error response data
247
+ * @returns {string} Formatted not found error message
248
+ */
249
+ function formatNotFoundError(errorData) {
250
+ const lines = [];
251
+ lines.push(chalk.red('❌ Not Found\n'));
252
+
253
+ // Extract detail from RFC 7807 Problem Details format
254
+ const detail = errorData.detail || errorData.message || '';
255
+
256
+ if (detail) {
257
+ lines.push(chalk.yellow(detail));
258
+ lines.push('');
259
+ }
260
+
261
+ // Provide actionable guidance based on error context
262
+ if (detail.toLowerCase().includes('environment')) {
263
+ lines.push(chalk.gray('Options:'));
264
+ lines.push(chalk.gray(' • Check the environment key spelling'));
265
+ lines.push(chalk.gray(' • List available environments: aifabrix app list -e <environment>'));
266
+ lines.push(chalk.gray(' • Verify you have access to this environment'));
267
+ } else if (detail.toLowerCase().includes('application')) {
268
+ lines.push(chalk.gray('Options:'));
269
+ lines.push(chalk.gray(' • Check the application key spelling'));
270
+ lines.push(chalk.gray(' • List applications: aifabrix app list -e <environment>'));
271
+ lines.push(chalk.gray(' • Verify the application exists in this environment'));
272
+ } else {
273
+ lines.push(chalk.gray('Options:'));
274
+ lines.push(chalk.gray(' • Check the resource identifier'));
275
+ lines.push(chalk.gray(' • Verify the resource exists'));
276
+ lines.push(chalk.gray(' • Check your permissions'));
277
+ }
278
+
279
+ if (errorData.correlationId) {
280
+ lines.push('');
281
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
282
+ }
283
+
284
+ return lines.join('\n');
285
+ }
286
+
287
+ /**
288
+ * Formats generic error
289
+ * @param {Object} errorData - Error response data
290
+ * @param {number} statusCode - HTTP status code
291
+ * @returns {string} Formatted error message
292
+ */
293
+ function formatGenericError(errorData, statusCode) {
294
+ const lines = [];
295
+ lines.push(chalk.red(`❌ Error (HTTP ${statusCode})\n`));
296
+
297
+ // Check for RFC 7807 Problem Details format (detail field)
298
+ if (errorData.detail) {
299
+ lines.push(chalk.yellow(errorData.detail));
300
+ } else if (errorData.message) {
301
+ lines.push(chalk.yellow(errorData.message));
302
+ } else if (errorData.error) {
303
+ lines.push(chalk.yellow(errorData.error));
304
+ } else {
305
+ lines.push(chalk.yellow('An error occurred while processing your request.'));
306
+ }
307
+
308
+ if (errorData.correlationId) {
309
+ lines.push('');
310
+ lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
311
+ }
312
+
313
+ return lines.join('\n');
314
+ }
315
+
316
+ /**
317
+ * Parses error response and determines error type
318
+ * @param {string|Object} errorResponse - Error response (string or parsed JSON)
319
+ * @param {number} statusCode - HTTP status code
320
+ * @param {boolean} isNetworkError - Whether this is a network error
321
+ * @returns {Object} Parsed error object with type, message, and formatted output
322
+ */
323
+ function parseErrorResponse(errorResponse, statusCode, isNetworkError) {
324
+ let errorData = {};
325
+
326
+ // Handle undefined or null error response
327
+ if (errorResponse === undefined || errorResponse === null) {
328
+ errorData = { message: 'Unknown error occurred' };
329
+ } else if (typeof errorResponse === 'string') {
330
+ // Parse error response
331
+ try {
332
+ errorData = JSON.parse(errorResponse);
333
+ } catch {
334
+ errorData = { message: errorResponse };
335
+ }
336
+ } else if (typeof errorResponse === 'object') {
337
+ errorData = errorResponse;
338
+ } else {
339
+ // Fallback for unexpected types
340
+ errorData = { message: String(errorResponse) };
341
+ }
342
+
343
+ // Handle nested response structure (some APIs wrap errors in data field)
344
+ if (errorData.data && typeof errorData.data === 'object') {
345
+ errorData = errorData.data;
346
+ }
347
+
348
+ // Handle network errors
349
+ if (isNetworkError) {
350
+ const errorMessage = errorData.message || errorResponse || 'Network error';
351
+ return {
352
+ type: 'network',
353
+ message: errorMessage,
354
+ formatted: formatNetworkError(errorMessage, errorData),
355
+ data: errorData
356
+ };
357
+ }
358
+
359
+ // Handle different HTTP status codes
360
+ if (statusCode === 403) {
361
+ // Permission error
362
+ return {
363
+ type: 'permission',
364
+ message: 'Permission denied',
365
+ formatted: formatPermissionError(errorData),
366
+ data: errorData
367
+ };
368
+ }
369
+
370
+ if (statusCode === 401) {
371
+ // Authentication error
372
+ return {
373
+ type: 'authentication',
374
+ message: 'Authentication failed',
375
+ formatted: formatAuthenticationError(errorData),
376
+ data: errorData
377
+ };
378
+ }
379
+
380
+ if (statusCode === 400) {
381
+ // Validation error
382
+ // Extract message from unified error format (RFC 7807)
383
+ const errorMessage = errorData.detail || errorData.title || errorData.message || 'Validation error';
384
+ return {
385
+ type: 'validation',
386
+ message: errorMessage,
387
+ formatted: formatValidationError(errorData),
388
+ data: errorData
389
+ };
390
+ }
391
+
392
+ if (statusCode === 404) {
393
+ // Not found error
394
+ return {
395
+ type: 'notfound',
396
+ message: errorData.detail || errorData.message || 'Not found',
397
+ formatted: formatNotFoundError(errorData),
398
+ data: errorData
399
+ };
400
+ }
401
+
402
+ if (statusCode === 409) {
403
+ // Conflict error
404
+ return {
405
+ type: 'conflict',
406
+ message: errorData.detail || errorData.message || 'Conflict',
407
+ formatted: formatConflictError(errorData),
408
+ data: errorData
409
+ };
410
+ }
411
+
412
+ if (statusCode >= 500) {
413
+ // Server error
414
+ return {
415
+ type: 'server',
416
+ message: 'Server error',
417
+ formatted: formatServerError(errorData),
418
+ data: errorData
419
+ };
420
+ }
421
+
422
+ // Generic error
423
+ return {
424
+ type: 'generic',
425
+ message: errorData.message || errorData.error || 'Unknown error',
426
+ formatted: formatGenericError(errorData, statusCode),
427
+ data: errorData
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Formats error for display in CLI
433
+ * @param {Object} apiResponse - API response object from makeApiCall
434
+ * @returns {string} Formatted error message
435
+ */
436
+ function formatApiError(apiResponse) {
437
+ if (!apiResponse || apiResponse.success !== false) {
438
+ return chalk.red('❌ Unknown error occurred');
439
+ }
440
+
441
+ // Use formattedError if already available
442
+ if (apiResponse.formattedError) {
443
+ return apiResponse.formattedError;
444
+ }
445
+
446
+ const errorResponse = apiResponse.error || apiResponse.data || '';
447
+ const statusCode = apiResponse.status || 0;
448
+ const isNetworkError = apiResponse.network === true;
449
+
450
+ const parsed = parseErrorResponse(errorResponse, statusCode, isNetworkError);
451
+ return parsed.formatted;
452
+ }
453
+
454
+ module.exports = {
455
+ parseErrorResponse,
456
+ formatApiError,
457
+ formatPermissionError,
458
+ formatValidationError,
459
+ formatAuthenticationError,
460
+ formatConflictError,
461
+ formatNetworkError,
462
+ formatServerError,
463
+ formatGenericError
464
+ };
465
+