@aifabrix/builder 2.32.2 → 2.33.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 (130) hide show
  1. package/.cursor/rules/project-rules.mdc +8 -0
  2. package/README.md +36 -8
  3. package/bin/aifabrix.js +6 -8
  4. package/integration/hubspot/README.md +8 -7
  5. package/integration/hubspot/companies.json +2048 -0
  6. package/integration/hubspot/create-hubspot.js +665 -0
  7. package/integration/hubspot/{hubspot-deploy-company.json → hubspot-datasource-company.json} +1 -1
  8. package/integration/hubspot/{hubspot-deploy-contact.json → hubspot-datasource-contact.json} +1 -1
  9. package/integration/hubspot/{hubspot-deploy-deal.json → hubspot-datasource-deal.json} +1 -1
  10. package/integration/hubspot/hubspot-deploy.json +832 -81
  11. package/integration/hubspot/hubspot-system.json +99 -0
  12. package/integration/hubspot/test-artifacts/wizard-hubspot-credential-real.yaml +20 -0
  13. package/integration/hubspot/test-artifacts/wizard-hubspot-env-vars.yaml +9 -0
  14. package/integration/hubspot/test-artifacts/wizard-invalid-add-datasource.yaml +5 -0
  15. package/integration/hubspot/test-artifacts/wizard-invalid-app-name.yaml +5 -0
  16. package/integration/hubspot/test-artifacts/wizard-invalid-credential-create.yaml +7 -0
  17. package/integration/hubspot/test-artifacts/wizard-invalid-credential-select.yaml +7 -0
  18. package/integration/hubspot/test-artifacts/wizard-invalid-known-platform.yaml +4 -0
  19. package/integration/hubspot/test-artifacts/wizard-invalid-missing-app.yaml +4 -0
  20. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
  21. package/integration/hubspot/test-artifacts/wizard-invalid-mode.yaml +5 -0
  22. package/integration/hubspot/test-artifacts/wizard-invalid-openapi-file.yaml +5 -0
  23. package/integration/hubspot/test-artifacts/wizard-invalid-openapi-url.yaml +4 -0
  24. package/integration/hubspot/test-artifacts/wizard-invalid-source.yaml +4 -0
  25. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-array-test.yaml +5 -0
  26. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
  27. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
  28. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-test.yaml +5 -0
  29. package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-test.yaml +5 -0
  30. package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +5 -0
  31. package/integration/hubspot/test-dataplane-down-helpers.js +246 -0
  32. package/integration/hubspot/test-dataplane-down-tests.js +419 -0
  33. package/integration/hubspot/test-dataplane-down.js +157 -0
  34. package/integration/hubspot/test.js +1517 -0
  35. package/integration/hubspot/variables.yaml +4 -4
  36. package/integration/hubspot/wizard-hubspot-e2e.yaml +16 -0
  37. package/integration/hubspot/wizard-hubspot-platform.yaml +8 -0
  38. package/lib/api/applications.api.js +1 -0
  39. package/lib/api/index.js +10 -5
  40. package/lib/api/types/wizard.types.js +176 -38
  41. package/lib/api/wizard.api.js +207 -38
  42. package/lib/app/deploy.js +116 -54
  43. package/lib/app/display.js +6 -5
  44. package/lib/app/dockerfile.js +2 -1
  45. package/lib/app/list.js +78 -37
  46. package/lib/app/prompts.js +9 -5
  47. package/lib/app/readme.js +41 -112
  48. package/lib/app/register.js +44 -9
  49. package/lib/app/rotate-secret.js +50 -32
  50. package/lib/cli.js +243 -65
  51. package/lib/commands/app.js +4 -9
  52. package/lib/commands/auth-config.js +125 -0
  53. package/lib/commands/auth-status.js +261 -0
  54. package/lib/commands/datasource.js +3 -6
  55. package/lib/commands/login-credentials.js +4 -4
  56. package/lib/commands/login-device.js +43 -29
  57. package/lib/commands/login.js +22 -13
  58. package/lib/commands/wizard-config-normalizer.js +92 -0
  59. package/lib/commands/wizard-core.js +515 -0
  60. package/lib/commands/wizard-dataplane.js +122 -0
  61. package/lib/commands/wizard-headless.js +115 -0
  62. package/lib/commands/wizard.js +129 -357
  63. package/lib/core/config.js +46 -0
  64. package/lib/core/secrets.js +3 -22
  65. package/lib/core/templates-env.js +1 -1
  66. package/lib/datasource/deploy.js +34 -23
  67. package/lib/datasource/list.js +8 -6
  68. package/lib/deployment/deployer.js +25 -0
  69. package/lib/deployment/environment.js +10 -13
  70. package/lib/external-system/delete.js +151 -0
  71. package/lib/external-system/deploy.js +54 -378
  72. package/lib/external-system/download-helpers.js +45 -65
  73. package/lib/external-system/download.js +34 -13
  74. package/lib/external-system/generator.js +11 -7
  75. package/lib/external-system/test-auth.js +5 -3
  76. package/lib/generator/builders.js +3 -1
  77. package/lib/generator/external-controller-manifest.js +157 -0
  78. package/lib/generator/external-schema-utils.js +236 -0
  79. package/lib/generator/external.js +55 -3
  80. package/lib/generator/index.js +22 -10
  81. package/lib/generator/wizard-prompts.js +33 -10
  82. package/lib/generator/wizard.js +69 -86
  83. package/lib/infrastructure/compose.js +100 -0
  84. package/lib/infrastructure/helpers.js +139 -0
  85. package/lib/infrastructure/index.js +52 -311
  86. package/lib/infrastructure/services.js +168 -0
  87. package/lib/schema/application-schema.json +24 -5
  88. package/lib/schema/external-datasource.schema.json +303 -17
  89. package/lib/schema/external-system.schema.json +1 -1
  90. package/lib/schema/wizard-config.schema.json +234 -0
  91. package/lib/utils/api.js +37 -42
  92. package/lib/utils/app-existence.js +42 -0
  93. package/lib/utils/app-register-config.js +7 -2
  94. package/lib/utils/app-register-display.js +2 -1
  95. package/lib/utils/auth-config-validator.js +92 -0
  96. package/lib/utils/cli-utils.js +3 -1
  97. package/lib/utils/command-header.js +43 -0
  98. package/lib/utils/compose-generator.js +113 -70
  99. package/lib/utils/controller-url.js +115 -0
  100. package/lib/utils/dataplane-health.js +115 -0
  101. package/lib/utils/dataplane-resolver.js +29 -0
  102. package/lib/utils/dev-config.js +6 -2
  103. package/lib/utils/env-copy.js +2 -1
  104. package/lib/utils/env-map.js +2 -1
  105. package/lib/utils/env-ports.js +2 -1
  106. package/lib/utils/env-template.js +1 -1
  107. package/lib/utils/error-formatter.js +149 -28
  108. package/lib/utils/external-readme.js +125 -0
  109. package/lib/utils/help-builder.js +190 -0
  110. package/lib/utils/infra-status.js +13 -3
  111. package/lib/utils/paths.js +17 -2
  112. package/lib/utils/port-resolver.js +111 -0
  113. package/lib/utils/secrets-helpers.js +3 -15
  114. package/lib/utils/secrets-utils.js +2 -2
  115. package/lib/utils/token-manager.js +69 -4
  116. package/lib/utils/variable-transformer.js +7 -2
  117. package/lib/validation/external-manifest-validator.js +202 -0
  118. package/lib/validation/validate-display.js +406 -0
  119. package/lib/validation/validate.js +159 -123
  120. package/lib/validation/validator.js +38 -4
  121. package/lib/validation/wizard-config-validator.js +267 -0
  122. package/package.json +4 -2
  123. package/templates/applications/README.md.hbs +19 -17
  124. package/templates/applications/miso-controller/env.template +1 -1
  125. package/templates/applications/miso-controller/rbac.yaml +7 -7
  126. package/templates/external-system/README.md.hbs +99 -0
  127. package/templates/external-system/external-system.json.hbs +1 -1
  128. package/templates/infra/compose.yaml.hbs +35 -0
  129. package/templates/python/docker-compose.hbs +26 -0
  130. package/templates/typescript/docker-compose.hbs +26 -0
package/lib/utils/api.js CHANGED
@@ -13,6 +13,9 @@
13
13
  const { parseErrorResponse } = require('./api-error-handler');
14
14
  const auditLogger = require('../core/audit-logger');
15
15
 
16
+ /** Default timeout for HTTP requests (ms). Prevents hanging when the controller is unreachable. */
17
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
18
+
16
19
  /**
17
20
  * Logs API request performance metrics and errors to audit log
18
21
  * @param {Object} params - Performance logging parameters
@@ -166,15 +169,20 @@ async function handleNetworkError(error, url, options, duration) {
166
169
 
167
170
  /**
168
171
  * Make an API call with proper error handling
172
+ * Uses a 15s timeout to avoid hanging when the controller is unreachable.
169
173
  * @param {string} url - API endpoint URL
170
- * @param {Object} options - Fetch options
174
+ * @param {Object} options - Fetch options (signal, method, headers, body, etc.)
171
175
  * @returns {Promise<Object>} Response object with success flag
172
176
  */
173
177
  async function makeApiCall(url, options = {}) {
174
178
  const startTime = Date.now();
179
+ const fetchOptions = { ...options };
180
+ if (!fetchOptions.signal) {
181
+ fetchOptions.signal = AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS);
182
+ }
175
183
 
176
184
  try {
177
- const response = await fetch(url, options);
185
+ const response = await fetch(url, fetchOptions);
178
186
  const duration = Date.now() - startTime;
179
187
 
180
188
  if (!response.ok) {
@@ -182,8 +190,13 @@ async function makeApiCall(url, options = {}) {
182
190
  }
183
191
 
184
192
  return await handleSuccessResponse(response, url, options, duration);
185
- } catch (error) {
193
+ } catch (err) {
186
194
  const duration = Date.now() - startTime;
195
+ const error = err?.name === 'AbortError'
196
+ ? new Error(
197
+ `Request timed out after ${DEFAULT_REQUEST_TIMEOUT_MS / 1000} seconds. The controller may be unreachable. Check the URL and network.`
198
+ )
199
+ : err;
187
200
  return await handleNetworkError(error, url, options, duration);
188
201
  }
189
202
  }
@@ -209,15 +222,21 @@ function extractControllerUrl(url) {
209
222
  * Automatically refreshes device token on 401 errors if refresh token is available
210
223
  * @param {string} url - API endpoint URL
211
224
  * @param {Object} options - Fetch options
212
- * @param {string} token - Bearer token
225
+ * @param {string|Object} tokenOrAuthConfig - Bearer token string or authConfig object
226
+ * @param {string} [tokenOrAuthConfig.token] - Bearer token (if object)
227
+ * @param {string} [tokenOrAuthConfig.controller] - Controller URL for token refresh (if object)
213
228
  * @returns {Promise<Object>} Response object
214
229
  */
215
- async function authenticatedApiCall(url, options = {}, token) {
216
- const headers = {
217
- 'Content-Type': 'application/json',
218
- ...options.headers
219
- };
220
-
230
+ // eslint-disable-next-line max-statements
231
+ async function authenticatedApiCall(url, options = {}, tokenOrAuthConfig) {
232
+ const isStringToken = typeof tokenOrAuthConfig === 'string';
233
+ const token = isStringToken ? tokenOrAuthConfig : tokenOrAuthConfig?.token;
234
+ const authControllerUrl = isStringToken ? null : tokenOrAuthConfig?.controller;
235
+ const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
236
+ const headers = { ...options.headers };
237
+ if (!isFormData && !headers['Content-Type']) {
238
+ headers['Content-Type'] = 'application/json';
239
+ }
221
240
  if (token) {
222
241
  headers['Authorization'] = `Bearer ${token}`;
223
242
  }
@@ -227,43 +246,19 @@ async function authenticatedApiCall(url, options = {}, token) {
227
246
  headers
228
247
  });
229
248
 
230
- // Handle 401 errors with automatic token refresh for device tokens
231
249
  if (!response.success && response.status === 401) {
232
250
  try {
233
- // Extract controller URL from request URL
234
- const controllerUrl = extractControllerUrl(url);
235
-
236
- // Try to get and refresh device token
237
- const { getOrRefreshDeviceToken } = require('./token-manager');
238
- const refreshedToken = await getOrRefreshDeviceToken(controllerUrl);
239
-
240
- if (refreshedToken && refreshedToken.token) {
241
- // Retry request with new token
251
+ const { forceRefreshDeviceToken } = require('./token-manager');
252
+ const refreshedToken = await forceRefreshDeviceToken(authControllerUrl || extractControllerUrl(url));
253
+ if (refreshedToken?.token) {
242
254
  headers['Authorization'] = `Bearer ${refreshedToken.token}`;
243
- const retryResponse = await makeApiCall(url, {
244
- ...options,
245
- headers
246
- });
247
- return retryResponse;
248
- }
249
-
250
- // Token refresh failed or no refresh token available
251
- // Return a more helpful error message
252
- if (!refreshedToken) {
253
- return {
254
- ...response,
255
- error: 'Authentication failed: Token expired and refresh failed. Please login again using: aifabrix login',
256
- formattedError: 'Authentication failed: Token expired and refresh failed. Please login again using: aifabrix login'
257
- };
255
+ return await makeApiCall(url, { ...options, headers });
258
256
  }
257
+ const authError = 'Authentication failed: Token expired and refresh failed. Please login again using: aifabrix login';
258
+ return { ...response, error: authError, formattedError: authError };
259
259
  } catch (refreshError) {
260
- // Refresh failed, return original 401 error with additional context
261
- const errorMessage = refreshError.message || String(refreshError);
262
- return {
263
- ...response,
264
- error: `Authentication failed: ${errorMessage}. Please login again using: aifabrix login`,
265
- formattedError: `Authentication failed: ${errorMessage}. Please login again using: aifabrix login`
266
- };
260
+ const authError = `Authentication failed: ${refreshError.message || String(refreshError)}. Please login again using: aifabrix login`;
261
+ return { ...response, error: authError, formattedError: authError };
267
262
  }
268
263
  }
269
264
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Application Existence Check Utility
3
+ *
4
+ * Checks if an application exists in an environment before deployment.
5
+ *
6
+ * @fileoverview Application existence checking for AI Fabrix Builder
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const { getEnvironmentApplication } = require('../api/environments.api');
12
+
13
+ /**
14
+ * Check if application exists in the environment
15
+ * Uses device token auth to check existence (doesn't require app credentials)
16
+ * @async
17
+ * @function checkApplicationExists
18
+ * @param {string} appKey - Application key
19
+ * @param {string} controllerUrl - Controller URL
20
+ * @param {string} envKey - Environment key
21
+ * @param {Object} authConfig - Authentication configuration (device token)
22
+ * @returns {Promise<boolean>} True if application exists, false otherwise
23
+ */
24
+ async function checkApplicationExists(appKey, controllerUrl, envKey, authConfig) {
25
+ try {
26
+ // Use device token auth (bearer token) to check if app exists
27
+ // This doesn't require app credentials, so it works even if credentials are wrong
28
+ const deviceAuthConfig = { type: 'bearer', token: authConfig.token };
29
+ const response = await getEnvironmentApplication(controllerUrl, envKey, appKey, deviceAuthConfig);
30
+ return response.success && response.data !== null && response.data !== undefined;
31
+ } catch (error) {
32
+ // If 404, application doesn't exist
33
+ if (error.status === 404 || (error.response && error.response.status === 404)) {
34
+ return false;
35
+ }
36
+ // For other errors (including 401 if device token is invalid), we can't determine existence
37
+ // Return false to avoid blocking deployment - the validation step will catch credential issues
38
+ return false;
39
+ }
40
+ }
41
+
42
+ module.exports = { checkApplicationExists };
@@ -14,6 +14,7 @@ const chalk = require('chalk');
14
14
  const yaml = require('js-yaml');
15
15
  const logger = require('./logger');
16
16
  const { detectAppType } = require('./paths');
17
+ const { getContainerPort, getLocalPort } = require('./port-resolver');
17
18
 
18
19
  // createApp is imported dynamically in createMinimalAppIfNeeded to handle test mocking
19
20
 
@@ -238,9 +239,11 @@ async function extractExternalAppConfiguration(appKey, variables, appKeyFromFile
238
239
  function extractWebappConfiguration(variables, appKeyFromFile, displayName, description, options) {
239
240
  const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
240
241
  const registryMode = variables.image?.registryMode || 'external';
241
- const port = variables.build?.port || options.port || 3000;
242
+ const port = options.port ?? getContainerPort(variables, 3000);
243
+ const localPort = getLocalPort(variables, port);
242
244
  const language = variables.build?.language || 'typescript';
243
245
  const image = buildImageReference(variables, appKeyFromFile);
246
+ const url = variables.app?.url || variables.deployment?.dataplaneUrl || variables.deployment?.appUrl || null;
244
247
 
245
248
  return {
246
249
  appKey: appKeyFromFile,
@@ -249,8 +252,10 @@ function extractWebappConfiguration(variables, appKeyFromFile, displayName, desc
249
252
  appType,
250
253
  registryMode,
251
254
  port,
255
+ localPort,
252
256
  image,
253
- language
257
+ language,
258
+ url
254
259
  };
255
260
  }
256
261
 
@@ -48,7 +48,8 @@ function displayRegistrationResults(data, apiUrl, environment) {
48
48
  logger.log(chalk.bold('📋 Application Details:'));
49
49
  logger.log(` ID: ${data.application.id}`);
50
50
  logger.log(` Key: ${data.application.key}`);
51
- logger.log(` Display Name: ${data.application.displayName}\n`);
51
+ logger.log(` Display Name: ${data.application.displayName}`);
52
+ logger.log(` Controller: ${apiUrl}\n`);
52
53
 
53
54
  logger.log(chalk.bold.yellow('🔑 CREDENTIALS (save these immediately):'));
54
55
  logger.log(chalk.yellow(` Client ID: ${data.credentials.clientId}`));
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Authentication Configuration Validators
3
+ *
4
+ * Provides validation functions for authentication configuration commands
5
+ *
6
+ * @fileoverview Authentication configuration validators
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const { getControllerUrlFromLoggedInUser } = require('./controller-url');
12
+
13
+ /**
14
+ * Validate controller URL format
15
+ * @function validateControllerUrl
16
+ * @param {string} url - Controller URL to validate
17
+ * @throws {Error} If URL format is invalid
18
+ */
19
+ function validateControllerUrl(url) {
20
+ if (!url || typeof url !== 'string') {
21
+ throw new Error('Controller URL is required and must be a string');
22
+ }
23
+
24
+ const trimmed = url.trim();
25
+ if (trimmed.length === 0) {
26
+ throw new Error('Controller URL cannot be empty');
27
+ }
28
+
29
+ // Basic URL validation - must start with http:// or https://
30
+ if (!trimmed.match(/^https?:\/\//)) {
31
+ throw new Error('Controller URL must start with http:// or https://');
32
+ }
33
+
34
+ try {
35
+ // Use URL constructor for more thorough validation
36
+ new URL(trimmed);
37
+ } catch (error) {
38
+ throw new Error(`Invalid controller URL format: ${error.message}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Validate environment key format
44
+ * @function validateEnvironment
45
+ * @param {string} env - Environment key to validate
46
+ * @throws {Error} If environment format is invalid
47
+ */
48
+ function validateEnvironment(env) {
49
+ if (!env || typeof env !== 'string') {
50
+ throw new Error('Environment is required and must be a string');
51
+ }
52
+
53
+ const trimmed = env.trim();
54
+ if (trimmed.length === 0) {
55
+ throw new Error('Environment cannot be empty');
56
+ }
57
+
58
+ // Environment key must contain only letters, numbers, hyphens, and underscores
59
+ if (!/^[a-z0-9-_]+$/i.test(trimmed)) {
60
+ throw new Error('Environment must contain only letters, numbers, hyphens, and underscores');
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Check if user is logged in to a controller
66
+ * @async
67
+ * @function checkUserLoggedIn
68
+ * @param {string} controllerUrl - Controller URL to check
69
+ * @returns {Promise<boolean>} True if user has device token for this controller
70
+ */
71
+ async function checkUserLoggedIn(controllerUrl) {
72
+ if (!controllerUrl) {
73
+ return false;
74
+ }
75
+
76
+ const normalizedUrl = controllerUrl.trim().replace(/\/+$/, '');
77
+ const loggedInControllerUrl = await getControllerUrlFromLoggedInUser();
78
+
79
+ if (!loggedInControllerUrl) {
80
+ return false;
81
+ }
82
+
83
+ // Normalize both URLs for comparison
84
+ const normalizedLoggedIn = loggedInControllerUrl.trim().replace(/\/+$/, '');
85
+ return normalizedLoggedIn === normalizedUrl;
86
+ }
87
+
88
+ module.exports = {
89
+ validateControllerUrl,
90
+ validateEnvironment,
91
+ checkUserLoggedIn
92
+ };
@@ -135,7 +135,9 @@ function formatAzureError(errorMsg) {
135
135
  ' Use format: *.azurecr.io (e.g., myacr.azurecr.io)'
136
136
  ];
137
137
  }
138
- if (errorMsg.includes('authenticate') || errorMsg.includes('ACR') || errorMsg.includes('Authentication required')) {
138
+ // Only match ACR-specific authentication errors, not general authentication failures
139
+ if (errorMsg.includes('ACR') || errorMsg.includes('azurecr.io') ||
140
+ (errorMsg.includes('authenticate') && (errorMsg.includes('registry') || errorMsg.includes('container')))) {
139
141
  return [
140
142
  ' Azure Container Registry authentication failed.',
141
143
  ' Run: az acr login --name <registry-name>',
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Command Header Display Utility
3
+ *
4
+ * Displays active configuration (controller, environment, dataplane) at top of commands
5
+ *
6
+ * @fileoverview Command header display utility
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const chalk = require('chalk');
12
+ const logger = require('./logger');
13
+
14
+ /**
15
+ * Display command header with active configuration
16
+ * @function displayCommandHeader
17
+ * @param {string} controllerUrl - Controller URL
18
+ * @param {string} environment - Environment key
19
+ * @param {string} [dataplaneUrl] - Dataplane URL (optional)
20
+ */
21
+ function displayCommandHeader(controllerUrl, environment, dataplaneUrl) {
22
+ const parts = [];
23
+
24
+ if (controllerUrl) {
25
+ parts.push(`Controller: ${chalk.cyan(controllerUrl)}`);
26
+ }
27
+
28
+ if (environment) {
29
+ parts.push(`Environment: ${chalk.cyan(environment)}`);
30
+ }
31
+
32
+ if (dataplaneUrl) {
33
+ parts.push(`Dataplane: ${chalk.cyan(dataplaneUrl)}`);
34
+ }
35
+
36
+ if (parts.length > 0) {
37
+ logger.log(chalk.gray(`\n${parts.join(' | ')}\n`));
38
+ }
39
+ }
40
+
41
+ module.exports = {
42
+ displayCommandHeader
43
+ };
@@ -15,6 +15,8 @@ const path = require('path');
15
15
  const handlebars = require('handlebars');
16
16
  const config = require('../core/config');
17
17
  const buildCopy = require('./build-copy');
18
+ const { formatMissingDbPasswordError } = require('./error-formatter');
19
+ const { getContainerPort } = require('./port-resolver');
18
20
 
19
21
  // Register commonly used helpers
20
22
  handlebars.registerHelper('eq', (a, b) => a === b);
@@ -150,6 +152,71 @@ function buildHealthCheckConfig(config) {
150
152
  };
151
153
  }
152
154
 
155
+ /**
156
+ * Derives base path from routing pattern by removing trailing wildcards
157
+ * @param {string} pattern - URL pattern (e.g., '/app/*', '/api/v1/*')
158
+ * @returns {string} Base path for routing
159
+ */
160
+ function derivePathFromPattern(pattern) {
161
+ if (!pattern || typeof pattern !== 'string') {
162
+ return '/';
163
+ }
164
+ const trimmed = pattern.trim();
165
+ if (trimmed === '/' || trimmed === '') {
166
+ return '/';
167
+ }
168
+ const withoutWildcards = trimmed.replace(/\*+$/g, '');
169
+ const withoutTrailingSlashes = withoutWildcards.replace(/\/+$/g, '');
170
+ return withoutTrailingSlashes || '/';
171
+ }
172
+
173
+ /**
174
+ * Builds developer username from developer ID
175
+ * @param {string|number} devId - Developer ID
176
+ * @returns {string} Developer username (dev, dev01, dev02, ...)
177
+ */
178
+ function buildDevUsername(devId) {
179
+ if (devId === undefined || devId === null) {
180
+ return 'dev';
181
+ }
182
+ const devIdString = String(devId);
183
+ if (devIdString === '0') {
184
+ return 'dev';
185
+ }
186
+ const paddedId = devIdString.length === 1 ? devIdString.padStart(2, '0') : devIdString;
187
+ return `dev${paddedId}`;
188
+ }
189
+
190
+ /**
191
+ * Builds Traefik ingress configuration from frontDoorRouting
192
+ * Resolves ${DEV_USERNAME} variable interpolation in host field
193
+ * @param {Object} config - Application configuration
194
+ * @param {string|number} devId - Developer ID
195
+ * @returns {Object} Traefik configuration object
196
+ */
197
+ function buildTraefikConfig(config, devId) {
198
+ const frontDoor = config.frontDoorRouting;
199
+ if (!frontDoor || frontDoor.enabled !== true) {
200
+ return { enabled: false };
201
+ }
202
+
203
+ if (!frontDoor.host || typeof frontDoor.host !== 'string') {
204
+ throw new Error('frontDoorRouting.host is required when frontDoorRouting.enabled is true');
205
+ }
206
+
207
+ const devUsername = buildDevUsername(devId);
208
+ const host = frontDoor.host.replace(/\$\{DEV_USERNAME\}/g, devUsername);
209
+ const path = derivePathFromPattern(frontDoor.pattern);
210
+
211
+ return {
212
+ enabled: true,
213
+ host,
214
+ path,
215
+ tls: frontDoor.tls !== false,
216
+ certStore: frontDoor.certStore || null
217
+ };
218
+ }
219
+
153
220
  /**
154
221
  * Builds requires configuration section
155
222
  * @param {Object} config - Application configuration
@@ -169,17 +236,12 @@ function buildRequiresConfig(config) {
169
236
  * @param {string} appName - Application name
170
237
  * @param {Object} config - Application configuration
171
238
  * @param {number} port - Application port
239
+ * @param {string|number} devId - Developer ID
172
240
  * @returns {Object} Service configuration
173
241
  */
174
- function buildServiceConfig(appName, config, port) {
175
- // Container port: build.containerPort > config.port (NEVER use host port parameter)
176
- // Container port should remain unchanged regardless of developer ID
177
- const containerPortValue = config.build?.containerPort || config.port || 3000;
178
-
179
- // Host port: use port parameter (already calculated from CLI --port or config.port in generateDockerCompose)
180
- // Note: build.localPort is ONLY used for .env file PORT variable (for local PC dev), NOT for Docker Compose
242
+ function buildServiceConfig(appName, config, port, devId) {
243
+ const containerPortValue = getContainerPort(config, 3000);
181
244
  const hostPort = port;
182
-
183
245
  return {
184
246
  app: buildAppConfig(appName, config),
185
247
  image: buildImageConfig(config, appName),
@@ -190,6 +252,7 @@ function buildServiceConfig(appName, config, port) {
190
252
  localPort: config.build?.localPort || null // Only used for .env file PORT variable, not for Docker Compose
191
253
  },
192
254
  healthCheck: buildHealthCheckConfig(config),
255
+ traefik: buildTraefikConfig(config, devId),
193
256
  ...buildRequiresConfig(config)
194
257
  };
195
258
  }
@@ -200,11 +263,7 @@ function buildServiceConfig(appName, config, port) {
200
263
  * @returns {Object} Volumes configuration
201
264
  */
202
265
  function buildVolumesConfig(appName) {
203
- // Use forward slashes for Docker paths (works on both Windows and Unix)
204
- const volumePath = path.join(process.cwd(), 'data', appName);
205
- return {
206
- mountVolume: volumePath.replace(/\\/g, '/')
207
- };
266
+ return { mountVolume: path.join(process.cwd(), 'data', appName).replace(/\\/g, '/') };
208
267
  }
209
268
 
210
269
  /**
@@ -213,23 +272,9 @@ function buildVolumesConfig(appName) {
213
272
  * @returns {Object} Networks configuration
214
273
  */
215
274
  function buildNetworksConfig(config) {
216
- // Get databases from requires.databases or top-level databases
217
- const databases = config.requires?.databases || config.databases || [];
218
- return {
219
- databases: databases
220
- };
275
+ return { databases: config.requires?.databases || config.databases || [] };
221
276
  }
222
277
 
223
- /**
224
- * Reads database passwords from .env file
225
- * Requires DB_0_PASSWORD, DB_1_PASSWORD, etc. to be set in .env file
226
- * @async
227
- * @param {string} envPath - Path to .env file
228
- * @param {Array<Object>} databases - Array of database configurations
229
- * @param {string} appKey - Application key (fallback for single database)
230
- * @returns {Promise<Object>} Object with passwords array and lookup map
231
- * @throws {Error} If required password variables are missing
232
- */
233
278
  /**
234
279
  * Reads and parses .env file
235
280
  * @async
@@ -276,17 +321,21 @@ async function readEnvFile(envPath) {
276
321
  * @function extractPassword
277
322
  * @param {Object} envVars - Environment variables
278
323
  * @param {string} passwordKey - Password key to look up
324
+ * @param {Object} [context] - Optional: { appKey, multi } for clearer error messages
279
325
  * @returns {string} Password value
280
326
  * @throws {Error} If password is missing or empty
281
327
  */
282
- function extractPassword(envVars, passwordKey) {
328
+ function extractPassword(envVars, passwordKey, context = {}) {
329
+ const { appKey, multi } = context;
330
+ const appSuffix = appKey ? ` for application '${appKey}'` : '';
331
+
283
332
  if (!(passwordKey in envVars)) {
284
- throw new Error(`Missing required password variable ${passwordKey} in .env file`);
333
+ throw new Error(multi && appKey ? formatMissingDbPasswordError(appKey, { multiDb: true, passwordKey }) : 'Missing required password variable ' + passwordKey + ' in .env file' + appSuffix + '. Add ' + passwordKey + '=your_secret to your .env file.');
285
334
  }
286
335
 
287
336
  const password = envVars[passwordKey].trim();
288
337
  if (!password || password.length === 0) {
289
- throw new Error(`Password variable ${passwordKey} is empty in .env file`);
338
+ throw new Error('Password variable ' + passwordKey + ' is empty in .env file' + appSuffix + '. Set a non-empty value.');
290
339
  }
291
340
 
292
341
  return password;
@@ -303,17 +352,14 @@ function extractPassword(envVars, passwordKey) {
303
352
  function processMultipleDatabases(databases, envVars, appKey) {
304
353
  const passwords = {};
305
354
  const passwordsArray = [];
306
-
307
355
  for (let i = 0; i < databases.length; i++) {
308
356
  const db = databases[i];
309
357
  const dbName = db.name || appKey;
310
358
  const passwordKey = `DB_${i}_PASSWORD`;
311
- const password = extractPassword(envVars, passwordKey);
312
-
359
+ const password = extractPassword(envVars, passwordKey, { appKey, multi: true });
313
360
  passwords[dbName] = password;
314
361
  passwordsArray.push(password);
315
362
  }
316
-
317
363
  return { passwords, passwordsArray };
318
364
  }
319
365
 
@@ -327,47 +373,36 @@ function processMultipleDatabases(databases, envVars, appKey) {
327
373
  function processSingleDatabase(envVars, appKey) {
328
374
  const passwords = {};
329
375
  const passwordsArray = [];
330
-
331
- // Single database case - use DB_0_PASSWORD or DB_PASSWORD
332
376
  const passwordKey = ('DB_0_PASSWORD' in envVars) ? 'DB_0_PASSWORD' : 'DB_PASSWORD';
333
-
334
377
  if (!(passwordKey in envVars)) {
335
- throw new Error(`Missing required password variable ${passwordKey} in .env file. Add DB_0_PASSWORD or DB_PASSWORD to your .env file.`);
378
+ throw new Error(formatMissingDbPasswordError(appKey));
336
379
  }
337
-
338
- const password = extractPassword(envVars, passwordKey);
380
+ const password = extractPassword(envVars, passwordKey, { appKey });
339
381
  passwords[appKey] = password;
340
382
  passwordsArray.push(password);
341
-
342
383
  return { passwords, passwordsArray };
343
384
  }
344
385
 
386
+ /**
387
+ * Reads database passwords from .env file
388
+ * @async
389
+ * @function readDatabasePasswords
390
+ * @param {string} envPath - Path to .env file
391
+ * @param {Array<Object>} databases - Array of database configurations
392
+ * @param {string} appKey - Application key (fallback for single database)
393
+ * @returns {Promise<Object>} Object with passwords map and array
394
+ * @throws {Error} If required password variables are missing
395
+ */
345
396
  async function readDatabasePasswords(envPath, databases, appKey) {
346
397
  const envVars = await readEnvFile(envPath);
347
-
348
- // Process each database
349
398
  if (databases && databases.length > 0) {
350
399
  const { passwords, passwordsArray } = processMultipleDatabases(databases, envVars, appKey);
351
- return {
352
- map: passwords,
353
- array: passwordsArray
354
- };
400
+ return { map: passwords, array: passwordsArray };
355
401
  }
356
-
357
402
  const { passwords, passwordsArray } = processSingleDatabase(envVars, appKey);
358
- return {
359
- map: passwords,
360
- array: passwordsArray
361
- };
403
+ return { map: passwords, array: passwordsArray };
362
404
  }
363
405
 
364
- /**
365
- * Generates Docker Compose configuration from template
366
- * @param {string} appName - Application name
367
- * @param {Object} appConfig - Application configuration
368
- * @param {Object} options - Run options
369
- * @returns {Promise<string>} Generated compose content
370
- */
371
406
  /**
372
407
  * Gets developer ID and calculates numeric ID
373
408
  * @async
@@ -376,8 +411,7 @@ async function readDatabasePasswords(envPath, databases, appKey) {
376
411
  */
377
412
  async function getDeveloperIdAndNumeric() {
378
413
  const devId = await config.getDeveloperId();
379
- const idNum = typeof devId === 'string' ? parseInt(devId, 10) : devId;
380
- return { devId, idNum };
414
+ return { devId, idNum: typeof devId === 'string' ? parseInt(devId, 10) : devId };
381
415
  }
382
416
 
383
417
  /**
@@ -411,15 +445,22 @@ async function readDatabasePasswordsIfNeeded(requiresDatabase, databases, envFil
411
445
  return { map: {}, array: [] };
412
446
  }
413
447
 
448
+ /**
449
+ * Generates Docker Compose configuration from template
450
+ * @async
451
+ * @function generateDockerCompose
452
+ * @param {string} appName - Application name
453
+ * @param {Object} appConfig - Application configuration
454
+ * @param {Object} options - Run options
455
+ * @returns {Promise<string>} Generated compose content
456
+ */
414
457
  async function generateDockerCompose(appName, appConfig, options) {
415
458
  const language = appConfig.build?.language || appConfig.language || 'typescript';
416
459
  const template = loadDockerComposeTemplate(language);
417
460
  const port = options.port || appConfig.port || 3000;
418
-
419
461
  const { devId, idNum } = await getDeveloperIdAndNumeric();
420
462
  const { networkName, containerName } = buildNetworkAndContainerNames(appName, devId, idNum);
421
-
422
- const serviceConfig = buildServiceConfig(appName, appConfig, port);
463
+ const serviceConfig = buildServiceConfig(appName, appConfig, port, devId);
423
464
  const volumesConfig = buildVolumesConfig(appName);
424
465
  const networksConfig = buildNetworksConfig(appConfig);
425
466
 
@@ -439,17 +480,19 @@ async function generateDockerCompose(appName, appConfig, options) {
439
480
  ...volumesConfig,
440
481
  ...networksConfig,
441
482
  envFile: envFileAbsolutePath,
442
- databasePasswords: databasePasswords,
483
+ databasePasswords,
443
484
  devId: idNum,
444
- networkName: networkName,
445
- containerName: containerName
485
+ networkName,
486
+ containerName
446
487
  };
447
-
448
488
  return template(templateData);
449
489
  }
450
490
 
451
491
  module.exports = {
452
492
  generateDockerCompose,
453
- getImageName
493
+ getImageName,
494
+ derivePathFromPattern,
495
+ buildTraefikConfig,
496
+ buildDevUsername
454
497
  };
455
498