@aifabrix/builder 2.7.0 → 2.8.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.
package/lib/config.js CHANGED
@@ -12,6 +12,7 @@ const fs = require('fs').promises;
12
12
  const path = require('path');
13
13
  const yaml = require('js-yaml');
14
14
  const os = require('os');
15
+ const { encryptToken, decryptToken, isTokenEncrypted } = require('./utils/token-encryption');
15
16
  // Avoid importing paths here to prevent circular dependency.
16
17
  // Config location is always under OS home at ~/.aifabrix/config.yaml
17
18
 
@@ -26,11 +27,6 @@ const RUNTIME_CONFIG_FILE = path.join(RUNTIME_CONFIG_DIR, 'config.yaml');
26
27
  // Cache for developer ID - loaded when getConfig() is first called
27
28
  let cachedDeveloperId = null;
28
29
 
29
- /**
30
- * Get stored configuration
31
- * Loads developer ID and caches it as a property for easy access
32
- * @returns {Promise<Object>} Configuration object with new structure
33
- */
34
30
  async function getConfig() {
35
31
  try {
36
32
  const configContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
@@ -156,61 +152,37 @@ async function getDeveloperId() {
156
152
  */
157
153
  async function setDeveloperId(developerId) {
158
154
  const DEV_ID_DIGITS_REGEX = /^[0-9]+$/;
155
+ const errorMsg = 'Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)';
159
156
  let devIdString;
160
157
  if (typeof developerId === 'number') {
161
- if (!Number.isFinite(developerId) || developerId < 0) {
162
- throw new Error('Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)');
163
- }
158
+ if (!Number.isFinite(developerId) || developerId < 0) throw new Error(errorMsg);
164
159
  devIdString = String(developerId);
165
160
  } else if (typeof developerId === 'string') {
166
- if (!DEV_ID_DIGITS_REGEX.test(developerId)) {
167
- throw new Error('Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)');
168
- }
161
+ if (!DEV_ID_DIGITS_REGEX.test(developerId)) throw new Error(errorMsg);
169
162
  devIdString = developerId;
170
163
  } else {
171
- throw new Error('Developer ID must be a non-negative digit string or number (0 = default infra, > 0 = developer-specific)');
164
+ throw new Error(errorMsg);
172
165
  }
173
- // Clear cache first to ensure we get fresh data from file
174
166
  cachedDeveloperId = null;
175
- // Read file directly to avoid any caching issues
176
167
  const config = await getConfig();
177
- // Update developer ID
178
168
  config['developer-id'] = devIdString;
179
- // Update cache before saving
180
169
  cachedDeveloperId = devIdString;
181
- // Save the entire config object to ensure all fields are preserved
182
170
  await saveConfig(config);
183
- // Verify the file was saved correctly by reading it back
184
- // This ensures the file system has written the data
185
- // Add a small delay to ensure file system has flushed the write
186
171
  await new Promise(resolve => setTimeout(resolve, 100));
187
- // Read file again with fresh file handle to avoid OS caching
188
172
  const savedContent = await fs.readFile(RUNTIME_CONFIG_FILE, 'utf8');
189
173
  const savedConfig = yaml.load(savedContent);
190
- // YAML may parse numbers as numbers, so convert to string for comparison
191
174
  const savedDevIdString = String(savedConfig['developer-id']);
192
175
  if (savedDevIdString !== devIdString) {
193
176
  throw new Error(`Failed to save developer ID: expected ${devIdString}, got ${savedDevIdString}. File content: ${savedContent.substring(0, 200)}`);
194
177
  }
195
- // Clear the cache to force reload from file on next getDeveloperId() call
196
- // This ensures we get the value that was actually saved to disk
197
178
  cachedDeveloperId = null;
198
179
  }
199
180
 
200
- /**
201
- * Get current environment from root-level config
202
- * @returns {Promise<string>} Current environment (defaults to 'dev')
203
- */
204
181
  async function getCurrentEnvironment() {
205
182
  const config = await getConfig();
206
183
  return config.environment || 'dev';
207
184
  }
208
185
 
209
- /**
210
- * Set current environment in root-level config
211
- * @param {string} environment - Environment to set (e.g., 'miso', 'dev', 'tst', 'pro')
212
- * @returns {Promise<void>}
213
- */
214
186
  async function setCurrentEnvironment(environment) {
215
187
  if (!environment || typeof environment !== 'string') {
216
188
  throw new Error('Environment must be a non-empty string');
@@ -220,31 +192,45 @@ async function setCurrentEnvironment(environment) {
220
192
  await saveConfig(config);
221
193
  }
222
194
 
223
- /**
224
- * Check if token is expired
225
- * @param {string} expiresAt - ISO timestamp string
226
- * @returns {boolean} True if token is expired
227
- */
228
195
  function isTokenExpired(expiresAt) {
229
196
  if (!expiresAt) return true;
230
197
  const expirationTime = new Date(expiresAt).getTime();
231
198
  const now = Date.now();
232
- return now >= (expirationTime - 5 * 60 * 1000); // 5 minute buffer
199
+ return now >= (expirationTime - 5 * 60 * 1000);
233
200
  }
234
201
 
235
- /**
236
- * Check if token should be refreshed proactively (within 15 minutes of expiry)
237
- * Helps keep Keycloak sessions alive by refreshing before SSO Session Idle timeout (30 minutes)
238
- * @param {string} expiresAt - ISO timestamp string
239
- * @returns {boolean} True if token should be refreshed proactively
240
- */
241
202
  function shouldRefreshToken(expiresAt) {
242
203
  if (!expiresAt) return true;
243
204
  const expirationTime = new Date(expiresAt).getTime();
244
205
  const now = Date.now();
245
- return now >= (expirationTime - 15 * 60 * 1000); // 15 minutes buffer
206
+ return now >= (expirationTime - 15 * 60 * 1000);
207
+ }
208
+ async function encryptTokenValue(value) {
209
+ if (!value || typeof value !== 'string') return value;
210
+ try {
211
+ const encryptionKey = await getSecretsEncryptionKey();
212
+ if (!encryptionKey) return value;
213
+ if (isTokenEncrypted(value)) return value;
214
+ const encrypted = encryptToken(value, encryptionKey);
215
+ // Ensure we never return undefined for valid inputs
216
+ return encrypted !== undefined && encrypted !== null ? encrypted : value;
217
+ } catch (error) {
218
+ return value;
219
+ }
220
+ }
221
+ async function decryptTokenValue(value) {
222
+ if (!value || typeof value !== 'string') return value;
223
+ try {
224
+ const encryptionKey = await getSecretsEncryptionKey();
225
+ if (!encryptionKey) return value;
226
+ if (!isTokenEncrypted(value)) return value;
227
+ const decrypted = decryptToken(value, encryptionKey);
228
+ // Ensure we never return undefined for valid inputs
229
+ return decrypted !== undefined && decrypted !== null ? decrypted : value;
230
+ } catch (error) {
231
+ return value;
232
+ }
246
233
  }
247
-
248
234
  /**
249
235
  * Get device token for controller
250
236
  * @param {string} controllerUrl - Controller URL
@@ -254,10 +240,37 @@ async function getDeviceToken(controllerUrl) {
254
240
  const config = await getConfig();
255
241
  if (!config.device || !config.device[controllerUrl]) return null;
256
242
  const deviceToken = config.device[controllerUrl];
243
+
244
+ // Migration: If tokens are plain text and encryption key exists, encrypt them first
245
+ const encryptionKey = await getSecretsEncryptionKey();
246
+ if (encryptionKey) {
247
+ let needsSave = false;
248
+
249
+ if (deviceToken.token && !isTokenEncrypted(deviceToken.token)) {
250
+ // Token is plain text, encrypt it
251
+ deviceToken.token = await encryptTokenValue(deviceToken.token);
252
+ needsSave = true;
253
+ }
254
+
255
+ if (deviceToken.refreshToken && !isTokenEncrypted(deviceToken.refreshToken)) {
256
+ // Refresh token is plain text, encrypt it
257
+ deviceToken.refreshToken = await encryptTokenValue(deviceToken.refreshToken);
258
+ needsSave = true;
259
+ }
260
+
261
+ if (needsSave) {
262
+ // Save encrypted tokens back to config
263
+ await saveConfig(config);
264
+ }
265
+ }
266
+ // Decrypt tokens if encrypted (for return value)
267
+ const token = deviceToken.token ? await decryptTokenValue(deviceToken.token) : undefined;
268
+ const refreshToken = deviceToken.refreshToken ? await decryptTokenValue(deviceToken.refreshToken) : null;
269
+
257
270
  return {
258
271
  controller: controllerUrl,
259
- token: deviceToken.token,
260
- refreshToken: deviceToken.refreshToken,
272
+ token: token,
273
+ refreshToken: refreshToken,
261
274
  expiresAt: deviceToken.expiresAt
262
275
  };
263
276
  }
@@ -272,7 +285,26 @@ async function getClientToken(environment, appName) {
272
285
  const config = await getConfig();
273
286
  if (!config.environments || !config.environments[environment]) return null;
274
287
  if (!config.environments[environment].clients || !config.environments[environment].clients[appName]) return null;
275
- return config.environments[environment].clients[appName];
288
+
289
+ const clientToken = config.environments[environment].clients[appName];
290
+
291
+ // Migration: If token is plain text and encryption key exists, encrypt it first
292
+ const encryptionKey = await getSecretsEncryptionKey();
293
+ if (encryptionKey && clientToken.token && !isTokenEncrypted(clientToken.token)) {
294
+ // Token is plain text, encrypt it
295
+ clientToken.token = await encryptTokenValue(clientToken.token);
296
+ // Save encrypted token back to config
297
+ await saveConfig(config);
298
+ }
299
+
300
+ // Decrypt token if encrypted (for return value)
301
+ const token = await decryptTokenValue(clientToken.token);
302
+
303
+ return {
304
+ controller: clientToken.controller,
305
+ token: token,
306
+ expiresAt: clientToken.expiresAt
307
+ };
276
308
  }
277
309
 
278
310
  /**
@@ -286,7 +318,16 @@ async function getClientToken(environment, appName) {
286
318
  async function saveDeviceToken(controllerUrl, token, refreshToken, expiresAt) {
287
319
  const config = await getConfig();
288
320
  if (!config.device) config.device = {};
289
- config.device[controllerUrl] = { token, refreshToken, expiresAt };
321
+
322
+ // Encrypt tokens before saving
323
+ const encryptedToken = await encryptTokenValue(token);
324
+ const encryptedRefreshToken = refreshToken ? await encryptTokenValue(refreshToken) : null;
325
+
326
+ config.device[controllerUrl] = {
327
+ token: encryptedToken,
328
+ refreshToken: encryptedRefreshToken,
329
+ expiresAt
330
+ };
290
331
  await saveConfig(config);
291
332
  }
292
333
 
@@ -304,7 +345,15 @@ async function saveClientToken(environment, appName, controllerUrl, token, expir
304
345
  if (!config.environments) config.environments = {};
305
346
  if (!config.environments[environment]) config.environments[environment] = { clients: {} };
306
347
  if (!config.environments[environment].clients) config.environments[environment].clients = {};
307
- config.environments[environment].clients[appName] = { controller: controllerUrl, token, expiresAt };
348
+
349
+ // Encrypt token before saving
350
+ const encryptedToken = await encryptTokenValue(token);
351
+
352
+ config.environments[environment].clients[appName] = {
353
+ controller: controllerUrl,
354
+ token: encryptedToken,
355
+ expiresAt
356
+ };
308
357
  await saveConfig(config);
309
358
  }
310
359
 
@@ -349,100 +398,56 @@ async function setSecretsEncryptionKey(key) {
349
398
  await saveConfig(config);
350
399
  }
351
400
 
352
- /**
353
- * Get general secrets path from configuration
354
- * Returns aifabrix-secrets path from config.yaml if configured
355
- * @returns {Promise<string|null>} Secrets path or null if not set
356
- */
357
401
  async function getSecretsPath() {
358
402
  const config = await getConfig();
359
- // Backward compatibility: prefer new key, fallback to legacy
360
403
  return config['aifabrix-secrets'] || config['secrets-path'] || null;
361
404
  }
362
405
 
363
- /**
364
- * Set general secrets path in configuration
365
- * @param {string} secretsPath - Path to general secrets file
366
- * @returns {Promise<void>}
367
- */
368
406
  async function setSecretsPath(secretsPath) {
369
407
  if (!secretsPath || typeof secretsPath !== 'string') {
370
408
  throw new Error('Secrets path is required and must be a string');
371
409
  }
372
-
373
410
  const config = await getConfig();
374
- // Store under new canonical key
375
411
  config['aifabrix-secrets'] = secretsPath;
376
412
  await saveConfig(config);
377
413
  }
378
414
 
379
- /**
380
- * Get aifabrix-home override from configuration
381
- * @returns {Promise<string|null>} Home override path or null if not set
382
- */
383
- async function getAifabrixHomeOverride() {
415
+ async function getPathConfig(key) {
384
416
  const config = await getConfig();
385
- return config['aifabrix-home'] || null;
417
+ return config[key] || null;
386
418
  }
387
419
 
388
- /**
389
- * Set aifabrix-home override in configuration
390
- * @param {string} homePath - Base directory path for AI Fabrix files
391
- * @returns {Promise<void>}
392
- */
393
- async function setAifabrixHomeOverride(homePath) {
394
- if (!homePath || typeof homePath !== 'string') {
395
- throw new Error('Home path is required and must be a string');
420
+ async function setPathConfig(key, value, errorMsg) {
421
+ if (!value || typeof value !== 'string') {
422
+ throw new Error(errorMsg);
396
423
  }
397
424
  const config = await getConfig();
398
- config['aifabrix-home'] = homePath;
425
+ config[key] = value;
399
426
  await saveConfig(config);
400
427
  }
401
428
 
402
- /**
403
- * Get aifabrix-secrets path from configuration (canonical)
404
- * @returns {Promise<string|null>} Secrets path or null if not set
405
- */
429
+ async function getAifabrixHomeOverride() {
430
+ return getPathConfig('aifabrix-home');
431
+ }
432
+
433
+ async function setAifabrixHomeOverride(homePath) {
434
+ await setPathConfig('aifabrix-home', homePath, 'Home path is required and must be a string');
435
+ }
436
+
406
437
  async function getAifabrixSecretsPath() {
407
- const config = await getConfig();
408
- return config['aifabrix-secrets'] || null;
438
+ return getPathConfig('aifabrix-secrets');
409
439
  }
410
440
 
411
- /**
412
- * Set aifabrix-secrets path in configuration (canonical)
413
- * @param {string} secretsPath - Path to default secrets file
414
- * @returns {Promise<void>}
415
- */
416
441
  async function setAifabrixSecretsPath(secretsPath) {
417
- if (!secretsPath || typeof secretsPath !== 'string') {
418
- throw new Error('Secrets path is required and must be a string');
419
- }
420
- const config = await getConfig();
421
- config['aifabrix-secrets'] = secretsPath;
422
- await saveConfig(config);
442
+ await setPathConfig('aifabrix-secrets', secretsPath, 'Secrets path is required and must be a string');
423
443
  }
424
444
 
425
- /**
426
- * Get aifabrix-env-config path from configuration
427
- * @returns {Promise<string|null>} Env config path or null if not set
428
- */
429
445
  async function getAifabrixEnvConfigPath() {
430
- const config = await getConfig();
431
- return config['aifabrix-env-config'] || null;
446
+ return getPathConfig('aifabrix-env-config');
432
447
  }
433
448
 
434
- /**
435
- * Set aifabrix-env-config path in configuration
436
- * @param {string} envConfigPath - Path to user env-config file
437
- * @returns {Promise<void>}
438
- */
439
449
  async function setAifabrixEnvConfigPath(envConfigPath) {
440
- if (!envConfigPath || typeof envConfigPath !== 'string') {
441
- throw new Error('Env config path is required and must be a string');
442
- }
443
- const config = await getConfig();
444
- config['aifabrix-env-config'] = envConfigPath;
445
- await saveConfig(config);
450
+ await setPathConfig('aifabrix-env-config', envConfigPath, 'Env config path is required and must be a string');
446
451
  }
447
452
 
448
453
  // Create exports object
@@ -461,6 +466,8 @@ const exportsObj = {
461
466
  getClientToken,
462
467
  saveDeviceToken,
463
468
  saveClientToken,
469
+ encryptTokenValue,
470
+ decryptTokenValue,
464
471
  getSecretsEncryptionKey,
465
472
  setSecretsEncryptionKey,
466
473
  getSecretsPath,
@@ -0,0 +1,305 @@
1
+ /**
2
+ * AI Fabrix Builder Environment Deployment Module
3
+ *
4
+ * Handles environment deployment/setup in Miso Controller.
5
+ * Sets up environment infrastructure before applications can be deployed.
6
+ *
7
+ * @fileoverview Environment deployment for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const chalk = require('chalk');
13
+ const logger = require('./utils/logger');
14
+ const config = require('./config');
15
+ const { validateControllerUrl, validateEnvironmentKey } = require('./utils/deployment-validation');
16
+ const { getOrRefreshDeviceToken } = require('./utils/token-manager');
17
+ const { authenticatedApiCall } = require('./utils/api');
18
+ const { handleDeploymentErrors } = require('./utils/deployment-errors');
19
+ const auditLogger = require('./audit-logger');
20
+
21
+ /**
22
+ * Validates environment deployment prerequisites
23
+ * @param {string} envKey - Environment key
24
+ * @param {string} controllerUrl - Controller URL
25
+ * @throws {Error} If prerequisites are not met
26
+ */
27
+ function validateEnvironmentPrerequisites(envKey, controllerUrl) {
28
+ if (!envKey || typeof envKey !== 'string') {
29
+ throw new Error('Environment key is required');
30
+ }
31
+
32
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
33
+ throw new Error('Controller URL is required');
34
+ }
35
+
36
+ // Validate environment key format
37
+ validateEnvironmentKey(envKey);
38
+
39
+ // Validate controller URL
40
+ validateControllerUrl(controllerUrl);
41
+ }
42
+
43
+ /**
44
+ * Gets authentication for environment deployment
45
+ * Uses device token (not app-specific client credentials)
46
+ * @async
47
+ * @param {string} controllerUrl - Controller URL
48
+ * @returns {Promise<Object>} Authentication configuration
49
+ * @throws {Error} If authentication is not available
50
+ */
51
+ async function getEnvironmentAuth(controllerUrl) {
52
+ const validatedUrl = validateControllerUrl(controllerUrl);
53
+
54
+ // Get or refresh device token
55
+ const deviceToken = await getOrRefreshDeviceToken(validatedUrl);
56
+
57
+ if (!deviceToken || !deviceToken.token) {
58
+ throw new Error('Device token is required for environment deployment. Run "aifabrix login" first to authenticate.');
59
+ }
60
+
61
+ return {
62
+ type: 'device',
63
+ token: deviceToken.token,
64
+ controller: deviceToken.controller || validatedUrl
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Sends environment deployment request to controller
70
+ * @async
71
+ * @param {string} controllerUrl - Controller URL
72
+ * @param {string} envKey - Environment key
73
+ * @param {Object} authConfig - Authentication configuration
74
+ * @param {Object} options - Deployment options
75
+ * @returns {Promise<Object>} Deployment result
76
+ * @throws {Error} If deployment fails
77
+ */
78
+ async function sendEnvironmentDeployment(controllerUrl, envKey, authConfig, options = {}) {
79
+ const validatedUrl = validateControllerUrl(controllerUrl);
80
+ const validatedEnvKey = validateEnvironmentKey(envKey);
81
+
82
+ // Build environment deployment request
83
+ const deploymentRequest = {
84
+ key: validatedEnvKey,
85
+ displayName: `${validatedEnvKey.charAt(0).toUpperCase() + validatedEnvKey.slice(1)} Environment`,
86
+ description: `${validatedEnvKey.charAt(0).toUpperCase() + validatedEnvKey.slice(1)} environment for application deployments`
87
+ };
88
+
89
+ // Add configuration if provided
90
+ if (options.config) {
91
+ // TODO: Load and parse config file if provided
92
+ // For now, just include the config path in description
93
+ deploymentRequest.description += ` (config: ${options.config})`;
94
+ }
95
+
96
+ // API endpoint: POST /api/v1/environments/{env}/deploy
97
+ // Alternative: POST /api/v1/environments/deploy with environment in body
98
+ const endpoint = `${validatedUrl}/api/v1/environments/${validatedEnvKey}/deploy`;
99
+
100
+ // Log deployment attempt for audit
101
+ await auditLogger.logDeploymentAttempt(validatedEnvKey, validatedUrl, options);
102
+
103
+ try {
104
+ const response = await authenticatedApiCall(
105
+ endpoint,
106
+ {
107
+ method: 'POST',
108
+ body: JSON.stringify(deploymentRequest)
109
+ },
110
+ authConfig.token
111
+ );
112
+
113
+ if (!response.success) {
114
+ const error = new Error(response.formattedError || response.error || 'Environment deployment failed');
115
+ error.status = response.status;
116
+ error.data = response.errorData;
117
+ throw error;
118
+ }
119
+
120
+ // Handle response structure
121
+ const responseData = response.data || {};
122
+ return {
123
+ success: true,
124
+ environment: validatedEnvKey,
125
+ deploymentId: responseData.deploymentId || responseData.id,
126
+ status: responseData.status || 'initiated',
127
+ url: responseData.url || `${validatedUrl}/environments/${validatedEnvKey}`,
128
+ message: responseData.message
129
+ };
130
+ } catch (error) {
131
+ // Use unified error handler
132
+ await handleDeploymentErrors(error, validatedEnvKey, validatedUrl, false);
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Polls environment deployment status
139
+ * @async
140
+ * @param {string} deploymentId - Deployment ID
141
+ * @param {string} controllerUrl - Controller URL
142
+ * @param {string} envKey - Environment key
143
+ * @param {Object} authConfig - Authentication configuration
144
+ * @param {Object} options - Polling options
145
+ * @returns {Promise<Object>} Final deployment status
146
+ */
147
+ async function pollEnvironmentStatus(deploymentId, controllerUrl, envKey, authConfig, options = {}) {
148
+ const validatedUrl = validateControllerUrl(controllerUrl);
149
+ const validatedEnvKey = validateEnvironmentKey(envKey);
150
+
151
+ const pollInterval = options.pollInterval || 5000;
152
+ const maxAttempts = options.maxAttempts || 60;
153
+ const statusEndpoint = `${validatedUrl}/api/v1/environments/${validatedEnvKey}/status`;
154
+
155
+ logger.log(chalk.blue(`⏳ Polling environment status (${pollInterval}ms intervals)...`));
156
+
157
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
158
+ try {
159
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
160
+
161
+ const response = await authenticatedApiCall(
162
+ statusEndpoint,
163
+ {
164
+ method: 'GET'
165
+ },
166
+ authConfig.token
167
+ );
168
+
169
+ if (response.success && response.data) {
170
+ const status = response.data.status || response.data.ready;
171
+ const isReady = status === 'ready' || status === 'completed' || response.data.ready === true;
172
+
173
+ if (isReady) {
174
+ return {
175
+ success: true,
176
+ environment: validatedEnvKey,
177
+ status: 'ready',
178
+ message: 'Environment is ready for application deployments'
179
+ };
180
+ }
181
+
182
+ // Check for terminal failure states
183
+ if (status === 'failed' || status === 'error') {
184
+ throw new Error(`Environment deployment failed: ${response.data.message || 'Unknown error'}`);
185
+ }
186
+ }
187
+ } catch (error) {
188
+ // If it's a terminal error (not a timeout), throw it
189
+ if (error.message && error.message.includes('failed')) {
190
+ throw error;
191
+ }
192
+ // Otherwise, continue polling
193
+ }
194
+
195
+ if (attempt < maxAttempts) {
196
+ logger.log(chalk.gray(` Attempt ${attempt}/${maxAttempts}...`));
197
+ }
198
+ }
199
+
200
+ // Timeout
201
+ throw new Error(`Environment deployment timeout after ${maxAttempts} attempts. Check controller logs for status.`);
202
+ }
203
+
204
+ /**
205
+ * Displays environment deployment results
206
+ * @param {Object} result - Deployment result
207
+ */
208
+ function displayDeploymentResults(result) {
209
+ logger.log(chalk.green('\n✅ Environment deployed successfully'));
210
+ logger.log(chalk.blue(` Environment: ${result.environment}`));
211
+ logger.log(chalk.blue(` Status: ${result.status === 'ready' ? '✅ ready' : result.status}`));
212
+ if (result.url) {
213
+ logger.log(chalk.blue(` URL: ${result.url}`));
214
+ }
215
+ if (result.deploymentId) {
216
+ logger.log(chalk.blue(` Deployment ID: ${result.deploymentId}`));
217
+ }
218
+ logger.log(chalk.green('\n✓ Environment is ready for application deployments'));
219
+ }
220
+
221
+ /**
222
+ * Deploys/setups an environment in the controller
223
+ * @async
224
+ * @function deployEnvironment
225
+ * @param {string} envKey - Environment key (miso, dev, tst, pro)
226
+ * @param {Object} options - Deployment options
227
+ * @param {string} options.controller - Controller URL (required)
228
+ * @param {string} [options.config] - Environment configuration file (optional)
229
+ * @param {boolean} [options.skipValidation] - Skip validation checks
230
+ * @param {boolean} [options.poll] - Poll for deployment status (default: true)
231
+ * @param {boolean} [options.noPoll] - Do not poll for status
232
+ * @returns {Promise<Object>} Deployment result
233
+ * @throws {Error} If deployment fails
234
+ *
235
+ * @example
236
+ * await deployEnvironment('dev', { controller: 'https://controller.aifabrix.ai' });
237
+ */
238
+ async function deployEnvironment(envKey, options = {}) {
239
+ try {
240
+ // 1. Input validation
241
+ if (!envKey || typeof envKey !== 'string' || envKey.trim().length === 0) {
242
+ throw new Error('Environment key is required');
243
+ }
244
+
245
+ const controllerUrl = options.controller || options['controller-url'];
246
+ if (!controllerUrl) {
247
+ throw new Error('Controller URL is required. Use --controller flag');
248
+ }
249
+
250
+ // 2. Validate prerequisites
251
+ if (!options.skipValidation) {
252
+ validateEnvironmentPrerequisites(envKey, controllerUrl);
253
+ }
254
+
255
+ // 3. Update root-level environment in config.yaml
256
+ await config.setCurrentEnvironment(envKey);
257
+
258
+ // 4. Get authentication (device token)
259
+ logger.log(chalk.blue(`\n📋 Deploying environment '${envKey}' to ${controllerUrl}...`));
260
+ const authConfig = await getEnvironmentAuth(controllerUrl);
261
+ logger.log(chalk.green('✓ Environment validated'));
262
+ logger.log(chalk.green('✓ Authentication successful'));
263
+
264
+ // 5. Send environment deployment request
265
+ logger.log(chalk.blue('\n🚀 Deploying environment infrastructure...'));
266
+ const validatedControllerUrl = validateControllerUrl(authConfig.controller);
267
+ const result = await sendEnvironmentDeployment(validatedControllerUrl, envKey, authConfig, options);
268
+
269
+ logger.log(chalk.blue(`📤 Sending deployment request to ${validatedControllerUrl}/api/v1/environments/${envKey}/deploy...`));
270
+
271
+ // 6. Poll for status if enabled
272
+ const shouldPoll = options.poll !== false && !options.noPoll;
273
+ if (shouldPoll && result.deploymentId) {
274
+ const pollResult = await pollEnvironmentStatus(
275
+ result.deploymentId,
276
+ validatedControllerUrl,
277
+ envKey,
278
+ authConfig,
279
+ {
280
+ pollInterval: 5000,
281
+ maxAttempts: 60
282
+ }
283
+ );
284
+ result.status = pollResult.status;
285
+ result.message = pollResult.message;
286
+ }
287
+
288
+ // 7. Display results
289
+ displayDeploymentResults(result);
290
+
291
+ return result;
292
+ } catch (error) {
293
+ // Error handling is done in sendEnvironmentDeployment and pollEnvironmentStatus
294
+ // Re-throw with context
295
+ if (error._logged !== true) {
296
+ logger.error(chalk.red(`\n❌ Environment deployment failed: ${error.message}`));
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ module.exports = {
303
+ deployEnvironment
304
+ };
305
+