@aifabrix/builder 2.40.2 → 2.42.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 (198) hide show
  1. package/.cursor/rules/docs-rules.mdc +30 -0
  2. package/README.md +7 -5
  3. package/integration/hubspot/README.md +8 -4
  4. package/integration/hubspot/application.json +54 -0
  5. package/integration/hubspot/create-hubspot.js +9 -136
  6. package/integration/hubspot/env.template +3 -4
  7. package/integration/hubspot/hubspot-datasource-company.json +343 -5
  8. package/integration/hubspot/hubspot-datasource-contact.json +413 -5
  9. package/integration/hubspot/hubspot-datasource-deal.json +341 -4
  10. package/integration/hubspot/hubspot-datasource-users.json +116 -0
  11. package/integration/hubspot/hubspot-deploy.json +1250 -108
  12. package/integration/hubspot/hubspot-system.json +15 -32
  13. package/integration/hubspot/test-dataplane-down-tests.js +17 -16
  14. package/integration/hubspot/test-dataplane-down.js +2 -2
  15. package/integration/hubspot/test.js +1 -1
  16. package/jest.config.manual.js +2 -1
  17. package/lib/api/credential.api.js +40 -0
  18. package/lib/api/dev.api.js +423 -0
  19. package/lib/api/external-test.api.js +111 -0
  20. package/lib/api/index.js +42 -19
  21. package/lib/api/pipeline.api.js +66 -120
  22. package/lib/api/types/credential.types.js +23 -0
  23. package/lib/api/types/dev.types.js +140 -0
  24. package/lib/api/types/pipeline.types.js +37 -0
  25. package/lib/api/wizard-platform.api.js +61 -0
  26. package/lib/api/wizard.api.js +34 -1
  27. package/lib/app/config.js +44 -11
  28. package/lib/app/down.js +2 -1
  29. package/lib/app/index.js +12 -1
  30. package/lib/app/prompts.js +44 -29
  31. package/lib/app/push.js +36 -12
  32. package/lib/app/readme.js +9 -6
  33. package/lib/app/run-env-compose.js +264 -0
  34. package/lib/app/run-helpers.js +121 -118
  35. package/lib/app/run.js +148 -28
  36. package/lib/app/show-display.js +1 -1
  37. package/lib/app/show.js +5 -2
  38. package/lib/build/index.js +11 -3
  39. package/lib/cli/setup-app.js +172 -15
  40. package/lib/cli/setup-credential-deployment.js +31 -6
  41. package/lib/cli/setup-dev.js +206 -16
  42. package/lib/cli/setup-environment.js +16 -6
  43. package/lib/cli/setup-external-system.js +89 -24
  44. package/lib/cli/setup-infra.js +82 -15
  45. package/lib/cli/setup-secrets.js +52 -5
  46. package/lib/cli/setup-utility.js +129 -24
  47. package/lib/commands/app-install.js +172 -0
  48. package/lib/commands/app-shell.js +75 -0
  49. package/lib/commands/app-test.js +282 -0
  50. package/lib/commands/app.js +1 -1
  51. package/lib/commands/credential-env.js +162 -0
  52. package/lib/commands/credential-list.js +17 -22
  53. package/lib/commands/credential-push.js +96 -0
  54. package/lib/commands/datasource.js +77 -6
  55. package/lib/commands/dev-cli-handlers.js +141 -0
  56. package/lib/commands/dev-down.js +114 -0
  57. package/lib/commands/dev-init.js +347 -0
  58. package/lib/commands/repair-auth-config.js +99 -0
  59. package/lib/commands/repair-datasource-keys.js +208 -0
  60. package/lib/commands/repair-datasource.js +235 -0
  61. package/lib/commands/repair-env-template.js +348 -0
  62. package/lib/commands/repair-internal.js +85 -0
  63. package/lib/commands/repair-rbac.js +158 -0
  64. package/lib/commands/repair.js +507 -0
  65. package/lib/commands/secrets-list.js +118 -0
  66. package/lib/commands/secrets-remove.js +97 -0
  67. package/lib/commands/secrets-set.js +30 -17
  68. package/lib/commands/secrets-validate.js +50 -0
  69. package/lib/commands/test-e2e-external.js +165 -0
  70. package/lib/commands/up-dataplane.js +2 -2
  71. package/lib/commands/up-miso.js +0 -25
  72. package/lib/commands/upload.js +96 -40
  73. package/lib/commands/wizard-core-helpers.js +226 -4
  74. package/lib/commands/wizard-core.js +67 -29
  75. package/lib/commands/wizard-dataplane.js +1 -1
  76. package/lib/commands/wizard-entity-selection.js +43 -0
  77. package/lib/commands/wizard-headless.js +44 -5
  78. package/lib/commands/wizard-helpers.js +7 -3
  79. package/lib/commands/wizard.js +86 -64
  80. package/lib/core/admin-secrets.js +96 -0
  81. package/lib/core/config.js +7 -1
  82. package/lib/core/secrets-ensure.js +378 -0
  83. package/lib/core/secrets-env-write.js +157 -0
  84. package/lib/core/secrets.js +176 -89
  85. package/lib/datasource/deploy.js +12 -3
  86. package/lib/datasource/field-reference-validator.js +91 -0
  87. package/lib/datasource/test-e2e.js +219 -0
  88. package/lib/datasource/test-integration.js +154 -0
  89. package/lib/datasource/validate.js +21 -3
  90. package/lib/deployment/deployer.js +7 -5
  91. package/lib/deployment/environment-config.js +137 -0
  92. package/lib/deployment/environment.js +21 -98
  93. package/lib/deployment/push.js +32 -2
  94. package/lib/external-system/download.js +188 -203
  95. package/lib/external-system/generator.js +204 -56
  96. package/lib/external-system/test-auth.js +7 -3
  97. package/lib/external-system/test-execution.js +2 -1
  98. package/lib/external-system/test-system-level.js +73 -0
  99. package/lib/external-system/test.js +56 -19
  100. package/lib/generator/external-controller-manifest.js +29 -2
  101. package/lib/generator/external-schema-utils.js +1 -1
  102. package/lib/generator/external.js +10 -3
  103. package/lib/generator/index.js +177 -25
  104. package/lib/generator/split-readme.js +1 -0
  105. package/lib/generator/split-variables.js +7 -1
  106. package/lib/generator/split.js +194 -54
  107. package/lib/generator/wizard-prompts-secondary.js +294 -0
  108. package/lib/generator/wizard-prompts.js +105 -106
  109. package/lib/generator/wizard-readme.js +88 -0
  110. package/lib/generator/wizard.js +155 -158
  111. package/lib/infrastructure/compose.js +11 -1
  112. package/lib/infrastructure/helpers.js +103 -20
  113. package/lib/infrastructure/index.js +98 -12
  114. package/lib/infrastructure/services.js +88 -22
  115. package/lib/schema/application-schema.json +32 -8
  116. package/lib/schema/external-datasource.schema.json +49 -26
  117. package/lib/schema/external-system.schema.json +509 -411
  118. package/lib/schema/wizard-config.schema.json +16 -0
  119. package/lib/utils/api.js +41 -13
  120. package/lib/utils/app-register-auth.js +25 -3
  121. package/lib/utils/auth-headers.js +8 -7
  122. package/lib/utils/cli-utils.js +20 -0
  123. package/lib/utils/compose-generator.js +77 -76
  124. package/lib/utils/compose-handlebars-helpers.js +54 -0
  125. package/lib/utils/compose-vector-helper.js +18 -0
  126. package/lib/utils/config-format-preference.js +51 -0
  127. package/lib/utils/config-format.js +36 -0
  128. package/lib/utils/config-paths.js +127 -2
  129. package/lib/utils/configuration-env-resolver.js +179 -0
  130. package/lib/utils/credential-display.js +83 -0
  131. package/lib/utils/credential-secrets-env.js +357 -0
  132. package/lib/utils/dataplane-pipeline-warning.js +28 -0
  133. package/lib/utils/deployment-validation-helpers.js +4 -4
  134. package/lib/utils/dev-ca-install.js +139 -0
  135. package/lib/utils/dev-cert-helper.js +122 -0
  136. package/lib/utils/device-code-helpers.js +224 -0
  137. package/lib/utils/device-code.js +37 -336
  138. package/lib/utils/docker-build.js +40 -8
  139. package/lib/utils/env-copy.js +103 -13
  140. package/lib/utils/env-map.js +35 -5
  141. package/lib/utils/env-template.js +6 -5
  142. package/lib/utils/error-formatters/http-status-errors.js +20 -2
  143. package/lib/utils/error-formatters/permission-errors.js +0 -1
  144. package/lib/utils/error-formatters/validation-errors.js +0 -1
  145. package/lib/utils/external-readme.js +56 -29
  146. package/lib/utils/external-system-display.js +59 -1
  147. package/lib/utils/external-system-test-helpers.js +21 -8
  148. package/lib/utils/external-system-validators.js +3 -0
  149. package/lib/utils/file-upload.js +20 -50
  150. package/lib/utils/help-builder.js +16 -2
  151. package/lib/utils/infra-status.js +80 -45
  152. package/lib/utils/local-secrets.js +7 -52
  153. package/lib/utils/mutagen-install.js +195 -0
  154. package/lib/utils/mutagen.js +146 -0
  155. package/lib/utils/paths.js +128 -37
  156. package/lib/utils/port-resolver.js +28 -16
  157. package/lib/utils/remote-dev-auth.js +38 -0
  158. package/lib/utils/remote-docker-env.js +43 -0
  159. package/lib/utils/remote-secrets-loader.js +60 -0
  160. package/lib/utils/secrets-canonical.js +93 -0
  161. package/lib/utils/secrets-generator.js +114 -6
  162. package/lib/utils/secrets-helpers.js +108 -114
  163. package/lib/utils/secrets-path.js +2 -2
  164. package/lib/utils/secrets-utils.js +52 -1
  165. package/lib/utils/secrets-validation.js +84 -0
  166. package/lib/utils/ssh-key-helper.js +116 -0
  167. package/lib/utils/test-log-writer.js +56 -0
  168. package/lib/utils/token-manager-messages.js +90 -0
  169. package/lib/utils/token-manager.js +29 -36
  170. package/lib/utils/variable-transformer.js +3 -3
  171. package/lib/validation/env-template-auth.js +157 -0
  172. package/lib/validation/env-template-kv.js +41 -0
  173. package/lib/validation/external-manifest-validator.js +25 -0
  174. package/lib/validation/external-system-auth-rules.js +86 -0
  175. package/lib/validation/validate-batch.js +149 -0
  176. package/lib/validation/validate-datasource-keys-api.js +33 -0
  177. package/lib/validation/validate-display.js +94 -16
  178. package/lib/validation/validate.js +25 -12
  179. package/lib/validation/validator.js +72 -9
  180. package/lib/validation/wizard-datasource-validation.js +50 -0
  181. package/package.json +8 -3
  182. package/scripts/install-local.js +34 -15
  183. package/templates/README.md +0 -1
  184. package/templates/applications/README.md.hbs +4 -4
  185. package/templates/applications/dataplane/application.yaml +6 -5
  186. package/templates/applications/dataplane/env.template +15 -10
  187. package/templates/applications/dataplane/rbac.yaml +2 -2
  188. package/templates/applications/keycloak/env.template +2 -0
  189. package/templates/applications/miso-controller/application.yaml +1 -0
  190. package/templates/applications/miso-controller/env.template +12 -10
  191. package/templates/external-system/README.md.hbs +65 -25
  192. package/templates/external-system/deploy.js.hbs +4 -2
  193. package/templates/external-system/external-datasource.yaml.hbs +217 -0
  194. package/templates/external-system/external-system.json.hbs +1 -18
  195. package/templates/infra/compose.yaml.hbs +6 -0
  196. package/templates/python/docker-compose.hbs +49 -23
  197. package/templates/typescript/docker-compose.hbs +48 -22
  198. package/integration/hubspot/application.yaml +0 -37
@@ -174,11 +174,14 @@ async function loadSystemFile(appPath, schemaBasePath, systemFileName) {
174
174
  * @param {string} appPath - Application path
175
175
  * @param {string} schemaBasePath - Schema base path
176
176
  * @param {Array<string>} datasourceFiles - Array of datasource file names
177
+ * @param {Object} [options] - Options
178
+ * @param {boolean} [options.skipMissingDatasourceFiles] - If true, skip missing files (e.g. when generating deploy JSON) instead of throwing
177
179
  * @returns {Promise<Array<Object>>} Array of datasource JSON objects
178
- * @throws {Error} If files cannot be loaded
180
+ * @throws {Error} If a file is missing and skipMissingDatasourceFiles is not set
179
181
  */
180
- async function loadDatasourceFiles(appPath, schemaBasePath, datasourceFiles) {
182
+ async function loadDatasourceFiles(appPath, schemaBasePath, datasourceFiles, options = {}) {
181
183
  const datasourceJsons = [];
184
+ const { skipMissingDatasourceFiles } = options;
182
185
 
183
186
  for (const datasourceFile of datasourceFiles) {
184
187
  const datasourcePath = path.isAbsolute(schemaBasePath)
@@ -186,6 +189,9 @@ async function loadDatasourceFiles(appPath, schemaBasePath, datasourceFiles) {
186
189
  : path.join(appPath, schemaBasePath, datasourceFile);
187
190
 
188
191
  if (!fs.existsSync(datasourcePath)) {
192
+ if (skipMissingDatasourceFiles) {
193
+ continue;
194
+ }
189
195
  throw new Error(`Datasource file not found: ${datasourcePath}`);
190
196
  }
191
197
 
@@ -417,6 +423,7 @@ module.exports = {
417
423
  generateExternalSystemApplicationSchema,
418
424
  splitExternalApplicationSchema,
419
425
  loadSystemFile,
420
- loadDatasourceFiles
426
+ loadDatasourceFiles,
427
+ loadExternalIntegrationConfig
421
428
  };
422
429
 
@@ -20,6 +20,8 @@ const { loadVariables, loadEnvTemplate, loadRbac, parseEnvironmentVariables } =
20
20
  const { generateExternalSystemApplicationSchema, splitExternalApplicationSchema } = require('./external');
21
21
  const { generateControllerManifest } = require('./external-controller-manifest');
22
22
  const { resolveVersionForApp } = require('../utils/image-version');
23
+ const { getContainerPort } = require('../utils/port-resolver');
24
+ const { buildEnvVarMap } = require('../utils/env-map');
23
25
 
24
26
  /**
25
27
  * Generates deployment JSON from application configuration files
@@ -59,6 +61,96 @@ function loadDeploymentConfigFiles(appPath, appType, appName) {
59
61
  return { variables, envTemplate, rbac, jsonPath };
60
62
  }
61
63
 
64
+ /** Placeholder replaced with application port from application.yaml */
65
+ const PORT_PLACEHOLDER = '${PORT}';
66
+
67
+ /**
68
+ * Returns the numeric port to use when substituting ${PORT} in the manifest.
69
+ * When application.yaml has port: "${PORT}", uses defaultPort (e.g. 3000).
70
+ *
71
+ * @param {Object} variables - Parsed application config
72
+ * @param {number} [defaultPort=3000] - Default when port is "${PORT}" or invalid
73
+ * @returns {number} Port number for substitution
74
+ */
75
+ function getEffectivePortForSubstitution(variables, defaultPort = 3000) {
76
+ const raw = getContainerPort(variables, defaultPort);
77
+ if (raw === PORT_PLACEHOLDER || (typeof raw === 'string' && raw.trim() === PORT_PLACEHOLDER)) {
78
+ return defaultPort;
79
+ }
80
+ if (typeof raw === 'number' && raw > 0) {
81
+ return raw;
82
+ }
83
+ const num = Number(raw);
84
+ return Number.isFinite(num) && num > 0 ? num : defaultPort;
85
+ }
86
+
87
+ /**
88
+ * Recursively replaces ${PORT} with the given port number in all string values of obj (in-place).
89
+ *
90
+ * @param {Object} obj - Deployment manifest or any nested object
91
+ * @param {number} portNumber - Port to substitute (e.g. from application.yaml)
92
+ */
93
+ function substitutePortInDeployment(obj, portNumber) {
94
+ if (obj === null || obj === undefined) return;
95
+ if (Array.isArray(obj)) {
96
+ for (let i = 0; i < obj.length; i++) {
97
+ if (typeof obj[i] === 'string' && obj[i].includes(PORT_PLACEHOLDER)) {
98
+ obj[i] = obj[i].split(PORT_PLACEHOLDER).join(String(portNumber));
99
+ } else if (typeof obj[i] === 'object' && obj[i] !== null) {
100
+ substitutePortInDeployment(obj[i], portNumber);
101
+ }
102
+ }
103
+ return;
104
+ }
105
+ if (typeof obj === 'object') {
106
+ for (const key of Object.keys(obj)) {
107
+ const value = obj[key];
108
+ if (typeof value === 'string' && value.includes(PORT_PLACEHOLDER)) {
109
+ obj[key] = value.split(PORT_PLACEHOLDER).join(String(portNumber));
110
+ } else if (typeof value === 'object' && value !== null) {
111
+ substitutePortInDeployment(value, portNumber);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ /** Regex to find ${VAR} placeholders for env substitution */
118
+ const ENV_VAR_PLACEHOLDER_REGEX = /\$\{([^}]+)\}/g;
119
+
120
+ /**
121
+ * Recursively replaces ${VAR} with envVarMap[VAR] in all string values of obj (in-place).
122
+ * Only substitutes when VAR is a key in envVarMap (from env-config.yaml / config).
123
+ *
124
+ * @param {Object} obj - Deployment manifest or any nested object
125
+ * @param {Object} envVarMap - Flat map of variable names to values (e.g. from buildEnvVarMap('docker'))
126
+ */
127
+ function substituteEnvVarsInDeployment(obj, envVarMap) {
128
+ if (!envVarMap || typeof envVarMap !== 'object') return;
129
+ if (obj === null || obj === undefined) return;
130
+ if (Array.isArray(obj)) {
131
+ for (let i = 0; i < obj.length; i++) {
132
+ if (typeof obj[i] === 'string') {
133
+ obj[i] = obj[i].replace(ENV_VAR_PLACEHOLDER_REGEX, (match, varName) =>
134
+ Object.prototype.hasOwnProperty.call(envVarMap, varName) ? String(envVarMap[varName]) : match);
135
+ } else if (typeof obj[i] === 'object' && obj[i] !== null) {
136
+ substituteEnvVarsInDeployment(obj[i], envVarMap);
137
+ }
138
+ }
139
+ return;
140
+ }
141
+ if (typeof obj === 'object') {
142
+ for (const key of Object.keys(obj)) {
143
+ const value = obj[key];
144
+ if (typeof value === 'string') {
145
+ obj[key] = value.replace(ENV_VAR_PLACEHOLDER_REGEX, (match, varName) =>
146
+ Object.prototype.hasOwnProperty.call(envVarMap, varName) ? String(envVarMap[varName]) : match);
147
+ } else if (typeof value === 'object' && value !== null) {
148
+ substituteEnvVarsInDeployment(value, envVarMap);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
62
154
  /**
63
155
  * Builds and validates deployment manifest
64
156
  * @function buildAndValidateDeployment
@@ -66,10 +158,12 @@ function loadDeploymentConfigFiles(appPath, appType, appName) {
66
158
  * @param {Object} variables - Variables configuration
67
159
  * @param {Object} envTemplate - Environment template
68
160
  * @param {Object} rbac - RBAC configuration
161
+ * @param {Object} [options] - Optional options
162
+ * @param {Object} [options.envVarMap] - Env vars from env-config (e.g. REDIS_HOST, DB_HOST) to resolve ${VAR} in manifest
69
163
  * @returns {Object} Deployment manifest
70
164
  * @throws {Error} If validation fails
71
165
  */
72
- function buildAndValidateDeployment(appName, variables, envTemplate, rbac) {
166
+ function buildAndValidateDeployment(appName, variables, envTemplate, rbac, options = null) {
73
167
  // Parse environment variables from template and merge portalInput from application config
74
168
  const configuration = parseEnvironmentVariables(envTemplate, variables);
75
169
 
@@ -83,6 +177,23 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac) {
83
177
  throw new Error(`Generated deployment JSON does not match schema:\n${errorMessages}`);
84
178
  }
85
179
 
180
+ // Replace ${PORT} with port from application.yaml so manifest deploys correctly
181
+ const effectivePort = getEffectivePortForSubstitution(variables, 3000);
182
+ substitutePortInDeployment(deployment, effectivePort);
183
+ if (deployment.port !== undefined) {
184
+ deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
185
+ ? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
186
+ }
187
+
188
+ // Resolve ${REDIS_HOST}, ${DB_HOST}, etc. from env-config.yaml so manifest has no unresolved vars
189
+ const envVarMap = options && options.envVarMap;
190
+ if (envVarMap) {
191
+ substituteEnvVarsInDeployment(deployment, envVarMap);
192
+ }
193
+
194
+ // Ensure no other ${...} placeholders remain in manifest
195
+ _validator.validateNoUnresolvedVariablesInDeployment(deployment);
196
+
86
197
  return deployment;
87
198
  }
88
199
 
@@ -117,46 +228,84 @@ async function buildDeploymentManifestInMemory(appName, options = {}) {
117
228
  const configuration = parseEnvironmentVariables(envTemplate, variables);
118
229
  const deployment = builders.buildManifestStructure(appName, variablesWithVersion, configuration, rbac);
119
230
 
231
+ const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
232
+ substitutePortInDeployment(deployment, effectivePort);
233
+ if (deployment.port !== undefined) {
234
+ deployment.port = typeof deployment.port === 'string' && /^\d+$/.test(deployment.port)
235
+ ? parseInt(deployment.port, 10) : (typeof deployment.port === 'number' ? deployment.port : effectivePort);
236
+ }
237
+ const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
238
+ substituteEnvVarsInDeployment(deployment, envVarMap);
239
+ _validator.validateNoUnresolvedVariablesInDeployment(deployment);
120
240
  return { deployment, appPath };
121
241
  }
122
242
 
123
- async function generateDeployJson(appName, options = {}) {
124
- if (!appName || typeof appName !== 'string') {
125
- throw new Error('App name is required and must be a string');
126
- }
127
-
128
- // Detect app type and get correct path (integration first, then builder)
129
- const { isExternal, appPath, appType } = await detectAppType(appName);
130
- logOfflinePathWhenType(appPath);
131
-
132
- // Check if app type is external
133
- if (isExternal) {
134
- const manifest = await generateControllerManifest(appName, options);
135
-
136
- // Determine system key for file naming
137
- const systemKey = manifest.key || appName;
138
- const deployJsonPath = path.join(appPath, `${systemKey}-deploy.json`);
139
-
140
- await fs.promises.writeFile(deployJsonPath, JSON.stringify(manifest, null, 2), { mode: 0o644, encoding: 'utf8' });
141
- return deployJsonPath;
243
+ /**
244
+ * Writes external system deploy JSON (manifest + ${PORT} substitution + validation).
245
+ * @async
246
+ * @param {string} appName - Application name
247
+ * @param {string} appPath - Application path
248
+ * @param {Object} options - Generation options
249
+ * @returns {Promise<string>} Path to written deploy JSON
250
+ */
251
+ async function writeExternalDeployJson(appName, appPath, options) {
252
+ const manifest = await generateControllerManifest(appName, {
253
+ ...options,
254
+ skipMissingDatasourceFiles: true
255
+ });
256
+ let effectivePort = 3000;
257
+ try {
258
+ const variablesPath = resolveApplicationConfigPath(appPath);
259
+ const { parsed: variables } = loadVariables(variablesPath);
260
+ effectivePort = getEffectivePortForSubstitution(variables, 3000);
261
+ substitutePortInDeployment(manifest, effectivePort);
262
+ } catch {
263
+ substitutePortInDeployment(manifest, 3000);
142
264
  }
265
+ const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
266
+ substituteEnvVarsInDeployment(manifest, envVarMap);
267
+ _validator.validateNoUnresolvedVariablesInDeployment(manifest);
268
+ const systemKey = manifest.key || appName;
269
+ const deployJsonPath = path.join(appPath, `${systemKey}-deploy.json`);
270
+ await fs.promises.writeFile(deployJsonPath, JSON.stringify(manifest, null, 2), { mode: 0o644, encoding: 'utf8' });
271
+ return deployJsonPath;
272
+ }
143
273
 
144
- // Regular app: generate deployment manifest
274
+ /**
275
+ * Writes regular app deploy JSON (build + validate + write).
276
+ * @async
277
+ * @param {string} appName - Application name
278
+ * @param {string} appPath - Application path
279
+ * @param {string} appType - Application type
280
+ * @returns {Promise<string>} Path to written deploy JSON
281
+ */
282
+ async function writeRegularDeployJson(appName, appPath, appType) {
145
283
  const { variables, envTemplate, rbac, jsonPath } = loadDeploymentConfigFiles(appPath, appType, appName);
146
284
  const resolved = await resolveVersionForApp(appName, variables, { updateBuilder: false });
147
285
  const variablesWithVersion = {
148
286
  ...variables,
149
287
  app: { ...variables.app, version: resolved.version }
150
288
  };
151
- const deployment = buildAndValidateDeployment(appName, variablesWithVersion, envTemplate, rbac);
152
-
153
- // Write deployment JSON
289
+ const effectivePort = getEffectivePortForSubstitution(variablesWithVersion, 3000);
290
+ const envVarMap = await buildEnvVarMap('docker', null, null, { appPort: effectivePort });
291
+ const deployment = buildAndValidateDeployment(appName, variablesWithVersion, envTemplate, rbac, { envVarMap });
154
292
  const jsonContent = JSON.stringify(deployment, null, 2);
155
293
  fs.writeFileSync(jsonPath, jsonContent, { mode: 0o644 });
156
-
157
294
  return jsonPath;
158
295
  }
159
296
 
297
+ async function generateDeployJson(appName, options = {}) {
298
+ if (!appName || typeof appName !== 'string') {
299
+ throw new Error('App name is required and must be a string');
300
+ }
301
+ const { isExternal, appPath, appType } = await detectAppType(appName);
302
+ logOfflinePathWhenType(appPath);
303
+ if (isExternal) {
304
+ return writeExternalDeployJson(appName, appPath, options);
305
+ }
306
+ return await writeRegularDeployJson(appName, appPath, appType);
307
+ }
308
+
160
309
  async function generateDeployJsonWithValidation(appName, options = {}) {
161
310
  const jsonPath = await generateDeployJson(appName, options);
162
311
  const jsonContent = fs.readFileSync(jsonPath, 'utf8');
@@ -187,6 +336,9 @@ module.exports = {
187
336
  generateDeployJson,
188
337
  generateDeployJsonWithValidation,
189
338
  buildDeploymentManifestInMemory,
339
+ getEffectivePortForSubstitution,
340
+ substitutePortInDeployment,
341
+ substituteEnvVarsInDeployment,
190
342
  generateExternalSystemApplicationSchema,
191
343
  splitExternalApplicationSchema,
192
344
  parseEnvironmentVariables,
@@ -25,6 +25,7 @@ function buildReadmeConfigForExternal(deployment) {
25
25
  systemType: system.type || 'openapi',
26
26
  systemDisplayName: system.displayName || appName,
27
27
  systemDescription: system.description || `External system integration for ${appName}`,
28
+ fileExt: '.yaml',
28
29
  datasourceCount: dataSources.length,
29
30
  datasources: dataSources
30
31
  }
@@ -78,10 +78,12 @@ function extractPortalInputConfiguration(configuration) {
78
78
 
79
79
  /**
80
80
  * Datasource filename for externalIntegration.dataSources.
81
+ * Avoids duplicate "datasource" in name (e.g. hubspot-users-datasource -> hubspot-datasource-users).
82
+ *
81
83
  * @param {string} systemKey - System key
82
84
  * @param {Object} datasource - Datasource from deployment.dataSources
83
85
  * @param {number} index - Index
84
- * @returns {string} Filename e.g. test-hubspot-datasource-companies-data.yaml
86
+ * @returns {string} Filename e.g. hubspot-datasource-users.yaml
85
87
  */
86
88
  function getExternalDatasourceFileName(systemKey, datasource, index) {
87
89
  const key = datasource.key || '';
@@ -90,6 +92,10 @@ function getExternalDatasourceFileName(systemKey, datasource, index) {
90
92
  else if (key.startsWith(`${systemKey}-`)) suffix = key.slice(systemKey.length + 1);
91
93
  else if (key) suffix = key;
92
94
  else suffix = datasource.entityType || datasource.entityKey || `entity${index + 1}`;
95
+ // Strip trailing -datasource so we get hubspot-datasource-users not hubspot-datasource-users-datasource
96
+ if (suffix.endsWith('-datasource')) {
97
+ suffix = suffix.slice(0, -'-datasource'.length);
98
+ }
93
99
  return `${systemKey}-datasource-${suffix}.yaml`;
94
100
  }
95
101
 
@@ -139,6 +139,125 @@ async function writeComponentFile(filePath, content) {
139
139
  await fs.writeFile(filePath, content, { mode: 0o644, encoding: 'utf8' });
140
140
  }
141
141
 
142
+ /**
143
+ * Builds key -> line map from configuration array (for env.template merge).
144
+ * @param {Array} configuration - Configuration array from deployment
145
+ * @returns {Map<string, string>} Key to full line map
146
+ */
147
+ function buildEnvTemplateExpectedByKey(configuration) {
148
+ const expectedByKey = new Map();
149
+ if (!Array.isArray(configuration)) return expectedByKey;
150
+ const lines = extractEnvTemplate(configuration).split('\n').filter(Boolean);
151
+ for (const line of lines) {
152
+ const eq = line.indexOf('=');
153
+ if (eq > 0) {
154
+ const key = line.substring(0, eq).trim();
155
+ expectedByKey.set(key, `${key}=${line.substring(eq + 1)}`);
156
+ }
157
+ }
158
+ return expectedByKey;
159
+ }
160
+
161
+ /**
162
+ * Pushes the appropriate line for a key that exists in expectedByKey (preserves MISO_CONTROLLER_URL).
163
+ * @param {string} line - Existing line
164
+ * @param {string} key - Parsed key
165
+ * @param {Map<string, string>} expectedByKey - Expected key -> line
166
+ * @param {string[]} updatedLines - Output lines
167
+ * @param {Set<string>} keysWritten - Keys already written
168
+ */
169
+ function pushMergedKeyValueLine(line, key, expectedByKey, updatedLines, keysWritten) {
170
+ const valueToPush = key === 'MISO_CONTROLLER_URL' ? line : expectedByKey.get(key);
171
+ updatedLines.push(valueToPush);
172
+ keysWritten.add(key);
173
+ }
174
+
175
+ /**
176
+ * Merges existing env.template with new lines from configuration; preserves comments and unknown keys.
177
+ * When MISO_CONTROLLER_URL already exists in the file, its value is preserved (only add when missing).
178
+ * @param {string} existingContent - Current env.template content
179
+ * @param {Map<string, string>} expectedByKey - New key -> line from download
180
+ * @returns {string} Merged content
181
+ */
182
+ function mergeEnvTemplateWithExisting(existingContent, expectedByKey) {
183
+ const lines = existingContent.split(/\r?\n/);
184
+ const updatedLines = [];
185
+ const keysWritten = new Set();
186
+ for (const line of lines) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed || trimmed.startsWith('#')) {
189
+ updatedLines.push(line);
190
+ continue;
191
+ }
192
+ const eq = line.indexOf('=');
193
+ if (eq <= 0) {
194
+ updatedLines.push(line);
195
+ continue;
196
+ }
197
+ const key = line.substring(0, eq).trim();
198
+ if (expectedByKey.has(key)) {
199
+ pushMergedKeyValueLine(line, key, expectedByKey, updatedLines, keysWritten);
200
+ } else {
201
+ updatedLines.push(line);
202
+ }
203
+ }
204
+ for (const key of expectedByKey.keys()) {
205
+ if (!keysWritten.has(key)) updatedLines.push(expectedByKey.get(key));
206
+ }
207
+ return updatedLines.join('\n') + (updatedLines.length > 0 ? '\n' : '');
208
+ }
209
+
210
+ /**
211
+ * Writes env.template (merge or overwrite).
212
+ * @param {string} outputDir - Output directory
213
+ * @param {string} envTemplate - Default env.template content
214
+ * @param {Object} options - Options (mergeEnvTemplate, configuration)
215
+ * @returns {Promise<string>} Path to env.template
216
+ */
217
+ async function writeEnvTemplateToDir(outputDir, envTemplate, options) {
218
+ const envTemplatePath = path.join(outputDir, 'env.template');
219
+ const fsSync = require('fs');
220
+ if (options.mergeEnvTemplate && options.configuration && fsSync.existsSync(envTemplatePath)) {
221
+ const expectedByKey = buildEnvTemplateExpectedByKey(options.configuration);
222
+ const existingContent = await fs.readFile(envTemplatePath, 'utf8');
223
+ const merged = mergeEnvTemplateWithExisting(existingContent, expectedByKey);
224
+ await writeComponentFile(envTemplatePath, merged);
225
+ } else {
226
+ await writeComponentFile(envTemplatePath, envTemplate);
227
+ }
228
+ return envTemplatePath;
229
+ }
230
+
231
+ /**
232
+ * Writes application.yaml, rbac.yaml, and README.md.
233
+ * @param {string} outputDir - Output directory
234
+ * @param {Object} variables - Variables object
235
+ * @param {Object|null} rbac - RBAC object or null
236
+ * @param {string} readme - README content
237
+ * @param {Object} options - Options (overwriteReadme)
238
+ * @returns {Promise<Object>} Results with variables, rbac?, readme? paths
239
+ */
240
+ async function writeVariablesRbacReadme(outputDir, variables, rbac, readme, options) {
241
+ const out = {};
242
+ const variablesPath = path.join(outputDir, 'application.yaml');
243
+ await writeComponentFile(variablesPath, yaml.dump(variables, { indent: 2, lineWidth: -1 }));
244
+ out.variables = variablesPath;
245
+ if (rbac) {
246
+ const rbacPath = path.join(outputDir, 'rbac.yaml');
247
+ await writeComponentFile(rbacPath, yaml.dump(rbac, { indent: 2, lineWidth: -1 }));
248
+ out.rbac = rbacPath;
249
+ }
250
+ const readmePath = path.join(outputDir, 'README.md');
251
+ const fsSync = require('fs');
252
+ if (options.overwriteReadme === false && fsSync.existsSync(readmePath)) {
253
+ out.readmeSkipped = readmePath;
254
+ } else {
255
+ await writeComponentFile(readmePath, readme);
256
+ out.readme = readmePath;
257
+ }
258
+ return out;
259
+ }
260
+
142
261
  /**
143
262
  * Writes all component files
144
263
  * @async
@@ -148,36 +267,36 @@ async function writeComponentFile(filePath, content) {
148
267
  * @param {Object} variables - Variables object
149
268
  * @param {Object|null} rbac - RBAC object or null
150
269
  * @param {string} readme - README content
270
+ * @param {Object} [options] - Options
271
+ * @param {boolean} [options.mergeEnvTemplate] - If true and env.template exists, merge instead of overwrite
272
+ * @param {Array} [options.configuration] - Configuration array for merge (required when mergeEnvTemplate)
273
+ * @param {boolean} [options.overwriteReadme] - If false and README.md exists, skip writing README
151
274
  * @returns {Promise<Object>} Results object with file paths
152
275
  */
153
- async function writeComponentFiles(outputDir, envTemplate, variables, rbac, readme) {
276
+ async function writeComponentFiles(outputDir, envTemplate, variables, rbac, readme, options = {}) {
154
277
  const results = {};
278
+ results.envTemplate = await writeEnvTemplateToDir(outputDir, envTemplate, options);
279
+ Object.assign(results, await writeVariablesRbacReadme(outputDir, variables, rbac, readme, options));
280
+ return results;
281
+ }
155
282
 
156
- // Write env.template
157
- const envTemplatePath = path.join(outputDir, 'env.template');
158
- await writeComponentFile(envTemplatePath, envTemplate);
159
- results.envTemplate = envTemplatePath;
160
-
161
- // Write application.yaml
162
- const variablesPath = path.join(outputDir, 'application.yaml');
163
- const variablesYaml = yaml.dump(variables, { indent: 2, lineWidth: -1 });
164
- await writeComponentFile(variablesPath, variablesYaml);
165
- results.variables = variablesPath;
166
-
167
- // Write rbac.yml (only if roles/permissions exist)
168
- if (rbac) {
169
- const rbacPath = path.join(outputDir, 'rbac.yml');
170
- const rbacYaml = yaml.dump(rbac, { indent: 2, lineWidth: -1 });
171
- await writeComponentFile(rbacPath, rbacYaml);
172
- results.rbac = rbacPath;
283
+ /**
284
+ * Writes datasource YAML files for external system.
285
+ * @param {string} outputDir - Output directory
286
+ * @param {string} systemKey - System key
287
+ * @param {Array} dataSourcesList - DataSources array
288
+ * @returns {Promise<string[]>} Paths to written datasource files
289
+ */
290
+ async function writeDatasourceFiles(outputDir, systemKey, dataSourcesList) {
291
+ const paths = [];
292
+ for (let i = 0; i < dataSourcesList.length; i++) {
293
+ const ds = dataSourcesList[i];
294
+ const fileName = getExternalDatasourceFileName(systemKey, ds, i);
295
+ const dsPath = path.join(outputDir, fileName);
296
+ await writeComponentFile(dsPath, yaml.dump(ds, { indent: 2, lineWidth: -1 }));
297
+ paths.push(dsPath);
173
298
  }
174
-
175
- // Write README.md
176
- const readmePath = path.join(outputDir, 'README.md');
177
- await writeComponentFile(readmePath, readme);
178
- results.readme = readmePath;
179
-
180
- return results;
299
+ return paths;
181
300
  }
182
301
 
183
302
  /**
@@ -194,25 +313,11 @@ async function writeExternalSystemAndDatasourceFiles(outputDir, deployment) {
194
313
  const system = deployment.system;
195
314
  const systemKey = system.key || 'external-system';
196
315
  const dataSourcesList = deployment.dataSources || deployment.datasources || [];
197
- const results = {};
198
-
316
+ const { roles: _roles, permissions: _permissions, ...systemWithoutRbac } = system;
199
317
  const systemPath = path.join(outputDir, `${systemKey}-system.yaml`);
200
- const systemYaml = yaml.dump(system, { indent: 2, lineWidth: -1 });
201
- await writeComponentFile(systemPath, systemYaml);
202
- results.systemFile = systemPath;
203
-
204
- const datasourcePaths = [];
205
- for (let i = 0; i < dataSourcesList.length; i++) {
206
- const ds = dataSourcesList[i];
207
- const fileName = getExternalDatasourceFileName(systemKey, ds, i);
208
- const dsPath = path.join(outputDir, fileName);
209
- const dsYaml = yaml.dump(ds, { indent: 2, lineWidth: -1 });
210
- await writeComponentFile(dsPath, dsYaml);
211
- datasourcePaths.push(dsPath);
212
- }
213
- results.datasourceFiles = datasourcePaths;
214
-
215
- return results;
318
+ await writeComponentFile(systemPath, yaml.dump(systemWithoutRbac, { indent: 2, lineWidth: -1 }));
319
+ const datasourcePaths = await writeDatasourceFiles(outputDir, systemKey, dataSourcesList);
320
+ return { systemFile: systemPath, datasourceFiles: datasourcePaths };
216
321
  }
217
322
 
218
323
  /**
@@ -247,7 +352,49 @@ function normalizeDeploymentForSplit(deployment) {
247
352
  * @returns {Promise<Object>} Object with paths to generated files
248
353
  * @throws {Error} If JSON file not found or invalid
249
354
  */
250
- async function splitDeployJson(deployJsonPath, outputDir = null) {
355
+ /**
356
+ * Builds write options for split from splitOptions and config array.
357
+ * @param {Object} splitOptions - Split options
358
+ * @param {Array} configArray - Deployment configuration array
359
+ * @returns {Object} Write options for writeComponentFiles
360
+ */
361
+ function buildSplitWriteOptions(splitOptions, configArray) {
362
+ const writeOptions = {};
363
+ if (splitOptions.mergeEnvTemplate) {
364
+ writeOptions.mergeEnvTemplate = true;
365
+ writeOptions.configuration = configArray;
366
+ }
367
+ if (splitOptions.overwriteReadme === false) {
368
+ writeOptions.overwriteReadme = false;
369
+ }
370
+ return writeOptions;
371
+ }
372
+
373
+ /**
374
+ * Writes external system/datasource files if deployment has system and assigns to result.
375
+ * @param {string} finalOutputDir - Output directory
376
+ * @param {Object} deployment - Deployment object
377
+ * @param {Object} result - Result object to mutate
378
+ */
379
+ async function applyExternalSystemFilesToResult(finalOutputDir, deployment, result) {
380
+ if (!deployment.system || typeof deployment.system !== 'object') {
381
+ return;
382
+ }
383
+ const externalFiles = await writeExternalSystemAndDatasourceFiles(finalOutputDir, deployment);
384
+ if (externalFiles.systemFile) result.systemFile = externalFiles.systemFile;
385
+ if (externalFiles.datasourceFiles && externalFiles.datasourceFiles.length > 0) {
386
+ result.datasourceFiles = externalFiles.datasourceFiles;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * @param {string} deployJsonPath - Path to deployment JSON file
392
+ * @param {string} [outputDir] - Directory to write component files
393
+ * @param {Object} [splitOptions] - Options for split behavior
394
+ * @param {boolean} [splitOptions.mergeEnvTemplate] - If true and env.template exists, merge download config into it
395
+ * @param {boolean} [splitOptions.overwriteReadme] - If false and README.md exists, do not overwrite
396
+ */
397
+ async function splitDeployJson(deployJsonPath, outputDir = null, splitOptions = {}) {
251
398
  validateDeployJsonPath(deployJsonPath);
252
399
  const finalOutputDir = await prepareOutputDirectory(deployJsonPath, outputDir);
253
400
  const deployment = await loadDeploymentJson(deployJsonPath);
@@ -259,16 +406,9 @@ async function splitDeployJson(deployJsonPath, outputDir = null) {
259
406
  const rbac = extractRbacYaml(deployment);
260
407
  const readme = generateReadmeFromDeployJson(deployment);
261
408
 
262
- const result = await writeComponentFiles(finalOutputDir, envTemplate, variables, rbac, readme);
263
-
264
- if (deployment.system && typeof deployment.system === 'object') {
265
- const externalFiles = await writeExternalSystemAndDatasourceFiles(finalOutputDir, deployment);
266
- if (externalFiles.systemFile) result.systemFile = externalFiles.systemFile;
267
- if (externalFiles.datasourceFiles && externalFiles.datasourceFiles.length > 0) {
268
- result.datasourceFiles = externalFiles.datasourceFiles;
269
- }
270
- }
271
-
409
+ const writeOptions = buildSplitWriteOptions(splitOptions, configArray);
410
+ const result = await writeComponentFiles(finalOutputDir, envTemplate, variables, rbac, readme, writeOptions);
411
+ await applyExternalSystemFilesToResult(finalOutputDir, deployment, result);
272
412
  return result;
273
413
  }
274
414