@aifabrix/builder 2.6.3 → 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.
Files changed (43) hide show
  1. package/.cursor/rules/project-rules.mdc +680 -0
  2. package/bin/aifabrix.js +4 -0
  3. package/lib/app-config.js +10 -0
  4. package/lib/app-deploy.js +18 -0
  5. package/lib/app-dockerfile.js +15 -0
  6. package/lib/app-prompts.js +172 -9
  7. package/lib/app-push.js +15 -0
  8. package/lib/app-register.js +14 -0
  9. package/lib/app-run.js +25 -0
  10. package/lib/app.js +30 -13
  11. package/lib/audit-logger.js +9 -4
  12. package/lib/build.js +8 -0
  13. package/lib/cli.js +99 -2
  14. package/lib/commands/datasource.js +94 -0
  15. package/lib/commands/login.js +40 -3
  16. package/lib/config.js +121 -114
  17. package/lib/datasource-deploy.js +182 -0
  18. package/lib/datasource-diff.js +73 -0
  19. package/lib/datasource-list.js +138 -0
  20. package/lib/datasource-validate.js +63 -0
  21. package/lib/diff.js +266 -0
  22. package/lib/environment-deploy.js +305 -0
  23. package/lib/external-system-deploy.js +262 -0
  24. package/lib/external-system-generator.js +187 -0
  25. package/lib/schema/application-schema.json +869 -698
  26. package/lib/schema/external-datasource.schema.json +512 -0
  27. package/lib/schema/external-system.schema.json +262 -0
  28. package/lib/schema/infrastructure-schema.json +1 -1
  29. package/lib/secrets.js +20 -1
  30. package/lib/templates.js +32 -1
  31. package/lib/utils/device-code.js +10 -2
  32. package/lib/utils/env-copy.js +24 -0
  33. package/lib/utils/env-endpoints.js +50 -11
  34. package/lib/utils/schema-loader.js +220 -0
  35. package/lib/utils/schema-resolver.js +174 -0
  36. package/lib/utils/secrets-helpers.js +65 -17
  37. package/lib/utils/token-encryption.js +68 -0
  38. package/lib/validate.js +299 -0
  39. package/lib/validator.js +47 -3
  40. package/package.json +1 -1
  41. package/tatus +181 -0
  42. package/templates/external-system/external-datasource.json.hbs +55 -0
  43. package/templates/external-system/external-system.json.hbs +37 -0
package/lib/diff.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * File Comparison Utilities
3
+ *
4
+ * Compares two configuration files and identifies differences.
5
+ * Used for deployment pipeline validation and schema migration detection.
6
+ *
7
+ * @fileoverview File comparison utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const chalk = require('chalk');
15
+ const logger = require('./utils/logger');
16
+
17
+ /**
18
+ * Performs deep comparison of two objects
19
+ * Returns differences as structured result
20
+ *
21
+ * @function compareObjects
22
+ * @param {Object} obj1 - First object
23
+ * @param {Object} obj2 - Second object
24
+ * @param {string} [path=''] - Current path in object (for nested fields)
25
+ * @returns {Object} Comparison result with added, removed, changed fields
26
+ */
27
+ function compareObjects(obj1, obj2, currentPath = '') {
28
+ const result = {
29
+ added: [],
30
+ removed: [],
31
+ changed: [],
32
+ identical: true
33
+ };
34
+
35
+ const allKeys = new Set([...Object.keys(obj1 || {}), ...Object.keys(obj2 || {})]);
36
+
37
+ for (const key of allKeys) {
38
+ const newPath = currentPath ? `${currentPath}.${key}` : key;
39
+ const val1 = obj1 && obj1[key];
40
+ const val2 = obj2 && obj2[key];
41
+
42
+ if (!(key in obj1)) {
43
+ result.added.push({
44
+ path: newPath,
45
+ value: val2,
46
+ type: typeof val2
47
+ });
48
+ result.identical = false;
49
+ } else if (!(key in obj2)) {
50
+ result.removed.push({
51
+ path: newPath,
52
+ value: val1,
53
+ type: typeof val1
54
+ });
55
+ result.identical = false;
56
+ } else if (typeof val1 === 'object' && typeof val2 === 'object' && val1 !== null && val2 !== null && !Array.isArray(val1) && !Array.isArray(val2)) {
57
+ // Recursively compare nested objects
58
+ const nestedResult = compareObjects(val1, val2, newPath);
59
+ result.added.push(...nestedResult.added);
60
+ result.removed.push(...nestedResult.removed);
61
+ result.changed.push(...nestedResult.changed);
62
+ if (!nestedResult.identical) {
63
+ result.identical = false;
64
+ }
65
+ } else if (JSON.stringify(val1) !== JSON.stringify(val2)) {
66
+ result.changed.push({
67
+ path: newPath,
68
+ oldValue: val1,
69
+ newValue: val2,
70
+ oldType: typeof val1,
71
+ newType: typeof val2
72
+ });
73
+ result.identical = false;
74
+ }
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Identifies breaking changes in comparison result
82
+ * Breaking changes include: removed required fields, type changes
83
+ *
84
+ * @function identifyBreakingChanges
85
+ * @param {Object} comparison - Comparison result from compareObjects
86
+ * @param {Object} schema1 - First file schema (optional, for required fields check)
87
+ * @param {Object} schema2 - Second file schema (optional, for required fields check)
88
+ * @returns {Array} Array of breaking change descriptions
89
+ */
90
+ function identifyBreakingChanges(comparison) {
91
+ const breaking = [];
92
+
93
+ // Removed fields are potentially breaking
94
+ comparison.removed.forEach(removed => {
95
+ breaking.push({
96
+ type: 'removed_field',
97
+ path: removed.path,
98
+ description: `Field removed: ${removed.path} (${removed.type})`
99
+ });
100
+ });
101
+
102
+ // Type changes are breaking
103
+ comparison.changed.forEach(change => {
104
+ if (change.oldType !== change.newType) {
105
+ breaking.push({
106
+ type: 'type_change',
107
+ path: change.path,
108
+ description: `Type changed: ${change.path} (${change.oldType} → ${change.newType})`
109
+ });
110
+ }
111
+ });
112
+
113
+ return breaking;
114
+ }
115
+
116
+ /**
117
+ * Compares two configuration files
118
+ * Loads files, parses JSON, and performs deep comparison
119
+ *
120
+ * @async
121
+ * @function compareFiles
122
+ * @param {string} file1 - Path to first file
123
+ * @param {string} file2 - Path to second file
124
+ * @returns {Promise<Object>} Comparison result with differences
125
+ * @throws {Error} If files cannot be read or parsed
126
+ *
127
+ * @example
128
+ * const result = await compareFiles('./old.json', './new.json');
129
+ * // Returns: { identical: false, added: [...], removed: [...], changed: [...] }
130
+ */
131
+ async function compareFiles(file1, file2) {
132
+ if (!file1 || typeof file1 !== 'string') {
133
+ throw new Error('First file path is required');
134
+ }
135
+ if (!file2 || typeof file2 !== 'string') {
136
+ throw new Error('Second file path is required');
137
+ }
138
+
139
+ // Validate files exist
140
+ if (!fs.existsSync(file1)) {
141
+ throw new Error(`File not found: ${file1}`);
142
+ }
143
+ if (!fs.existsSync(file2)) {
144
+ throw new Error(`File not found: ${file2}`);
145
+ }
146
+
147
+ // Read and parse files
148
+ let content1, content2;
149
+ let parsed1, parsed2;
150
+
151
+ try {
152
+ content1 = fs.readFileSync(file1, 'utf8');
153
+ parsed1 = JSON.parse(content1);
154
+ } catch (error) {
155
+ throw new Error(`Failed to parse ${file1}: ${error.message}`);
156
+ }
157
+
158
+ try {
159
+ content2 = fs.readFileSync(file2, 'utf8');
160
+ parsed2 = JSON.parse(content2);
161
+ } catch (error) {
162
+ throw new Error(`Failed to parse ${file2}: ${error.message}`);
163
+ }
164
+
165
+ // Compare objects
166
+ const comparison = compareObjects(parsed1, parsed2);
167
+
168
+ // Check for version changes
169
+ const version1 = parsed1.version || parsed1.metadata?.version || 'unknown';
170
+ const version2 = parsed2.version || parsed2.metadata?.version || 'unknown';
171
+ const versionChanged = version1 !== version2;
172
+
173
+ // Identify breaking changes
174
+ const breakingChanges = identifyBreakingChanges(comparison);
175
+
176
+ return {
177
+ identical: comparison.identical && !versionChanged,
178
+ file1: path.basename(file1),
179
+ file2: path.basename(file2),
180
+ version1,
181
+ version2,
182
+ versionChanged,
183
+ added: comparison.added,
184
+ removed: comparison.removed,
185
+ changed: comparison.changed,
186
+ breakingChanges,
187
+ summary: {
188
+ totalAdded: comparison.added.length,
189
+ totalRemoved: comparison.removed.length,
190
+ totalChanged: comparison.changed.length,
191
+ totalBreaking: breakingChanges.length
192
+ }
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Formats and displays diff output
198
+ * Shows differences in a user-friendly format with color coding
199
+ *
200
+ * @function formatDiffOutput
201
+ * @param {Object} diffResult - Comparison result from compareFiles
202
+ */
203
+ function formatDiffOutput(diffResult) {
204
+ logger.log(chalk.blue(`\nComparing: ${diffResult.file1} ↔ ${diffResult.file2}`));
205
+
206
+ if (diffResult.identical) {
207
+ logger.log(chalk.green('\n✓ Files are identical'));
208
+ return;
209
+ }
210
+
211
+ logger.log(chalk.yellow('\nFiles are different'));
212
+
213
+ // Version information
214
+ if (diffResult.versionChanged) {
215
+ logger.log(chalk.blue(`\nVersion: ${diffResult.version1} → ${diffResult.version2}`));
216
+ }
217
+
218
+ // Breaking changes
219
+ if (diffResult.breakingChanges.length > 0) {
220
+ logger.log(chalk.red('\n⚠️ Breaking Changes:'));
221
+ diffResult.breakingChanges.forEach(change => {
222
+ logger.log(chalk.red(` • ${change.description}`));
223
+ });
224
+ }
225
+
226
+ // Added fields
227
+ if (diffResult.added.length > 0) {
228
+ logger.log(chalk.green('\nAdded Fields:'));
229
+ diffResult.added.forEach(field => {
230
+ logger.log(chalk.green(` + ${field.path}: ${JSON.stringify(field.value)}`));
231
+ });
232
+ }
233
+
234
+ // Removed fields
235
+ if (diffResult.removed.length > 0) {
236
+ logger.log(chalk.red('\nRemoved Fields:'));
237
+ diffResult.removed.forEach(field => {
238
+ logger.log(chalk.red(` - ${field.path}: ${JSON.stringify(field.value)}`));
239
+ });
240
+ }
241
+
242
+ // Changed fields
243
+ if (diffResult.changed.length > 0) {
244
+ logger.log(chalk.yellow('\nChanged Fields:'));
245
+ diffResult.changed.forEach(change => {
246
+ logger.log(chalk.yellow(` ~ ${change.path}:`));
247
+ logger.log(chalk.gray(` Old: ${JSON.stringify(change.oldValue)}`));
248
+ logger.log(chalk.gray(` New: ${JSON.stringify(change.newValue)}`));
249
+ });
250
+ }
251
+
252
+ // Summary
253
+ logger.log(chalk.blue('\nSummary:'));
254
+ logger.log(chalk.blue(` Added: ${diffResult.summary.totalAdded}`));
255
+ logger.log(chalk.blue(` Removed: ${diffResult.summary.totalRemoved}`));
256
+ logger.log(chalk.blue(` Changed: ${diffResult.summary.totalChanged}`));
257
+ logger.log(chalk.blue(` Breaking: ${diffResult.summary.totalBreaking}`));
258
+ }
259
+
260
+ module.exports = {
261
+ compareFiles,
262
+ formatDiffOutput,
263
+ compareObjects,
264
+ identifyBreakingChanges
265
+ };
266
+
@@ -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
+