@aifabrix/builder 2.42.1 → 2.43.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 (117) hide show
  1. package/README.md +1 -1
  2. package/bin/aifabrix.js +1 -1
  3. package/integration/hubspot-test/README.md +126 -0
  4. package/integration/{hubspot → hubspot-test}/application.json +6 -6
  5. package/integration/{hubspot → hubspot-test}/create-hubspot.js +5 -5
  6. package/integration/hubspot-test/env.template +4 -0
  7. package/integration/{hubspot/hubspot-datasource-company.json → hubspot-test/hubspot-test-datasource-company.json} +3 -2
  8. package/integration/{hubspot/hubspot-datasource-contact.json → hubspot-test/hubspot-test-datasource-contact.json} +3 -2
  9. package/integration/{hubspot/hubspot-datasource-deal.json → hubspot-test/hubspot-test-datasource-deal.json} +3 -2
  10. package/integration/{hubspot/hubspot-datasource-users.json → hubspot-test/hubspot-test-datasource-users.json} +3 -2
  11. package/integration/{hubspot/hubspot-deploy.json → hubspot-test/hubspot-test-deploy.json} +198 -21
  12. package/integration/{hubspot/hubspot-system.json → hubspot-test/hubspot-test-system.json} +8 -7
  13. package/integration/hubspot-test/rbac.json +166 -0
  14. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-credential-real.yaml +3 -3
  15. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-hubspot-env-vars.yaml +2 -2
  16. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-add-datasource.yaml +1 -1
  17. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-create.yaml +1 -1
  18. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-credential-select.yaml +1 -1
  19. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-known-platform.yaml +1 -1
  20. package/integration/hubspot-test/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
  21. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-mode.yaml +1 -1
  22. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-file.yaml +1 -1
  23. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-openapi-url.yaml +1 -1
  24. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-source.yaml +1 -1
  25. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-array-test.yaml +1 -1
  26. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
  27. package/integration/hubspot-test/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
  28. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-dimension-test.yaml +1 -1
  29. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-test.yaml +1 -1
  30. package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +1 -1
  31. package/integration/{hubspot → hubspot-test}/test.js +102 -59
  32. package/integration/{hubspot → hubspot-test}/wizard-hubspot-e2e.yaml +2 -2
  33. package/integration/{hubspot → hubspot-test}/wizard-hubspot-platform.yaml +1 -1
  34. package/lib/api/external-test.api.js +1 -1
  35. package/lib/api/service-users.api.js +111 -2
  36. package/lib/api/types/service-users.types.js +41 -0
  37. package/lib/app/register.js +3 -1
  38. package/lib/app/rotate-secret.js +3 -0
  39. package/lib/cli/setup-app.js +2 -2
  40. package/lib/cli/setup-auth.js +19 -11
  41. package/lib/cli/setup-dev.js +62 -32
  42. package/lib/cli/setup-environment.js +6 -21
  43. package/lib/cli/setup-infra.js +13 -0
  44. package/lib/cli/setup-secrets.js +45 -6
  45. package/lib/cli/setup-service-user.js +146 -20
  46. package/lib/cli/setup-utility.js +12 -0
  47. package/lib/commands/auth-config.js +4 -8
  48. package/lib/commands/datasource.js +46 -1
  49. package/lib/commands/dev-init.js +1 -1
  50. package/lib/commands/repair-env-template.js +14 -8
  51. package/lib/commands/repair-rbac.js +25 -19
  52. package/lib/commands/repair.js +96 -30
  53. package/lib/commands/secrets-remove.js +1 -1
  54. package/lib/commands/secrets-validate.js +17 -4
  55. package/lib/commands/service-user.js +231 -2
  56. package/lib/commands/up-common.js +25 -0
  57. package/lib/commands/up-dataplane.js +2 -2
  58. package/lib/core/admin-secrets.js +2 -0
  59. package/lib/core/config.js +7 -5
  60. package/lib/core/ensure-encryption-key.js +1 -3
  61. package/lib/core/secrets.js +32 -9
  62. package/lib/core/templates.js +1 -1
  63. package/lib/datasource/abac-validator.js +157 -0
  64. package/lib/datasource/field-reference-validator.js +74 -36
  65. package/lib/datasource/log-viewer.js +221 -0
  66. package/lib/datasource/resolve-app.js +109 -0
  67. package/lib/datasource/test-e2e.js +11 -20
  68. package/lib/datasource/test-integration.js +42 -22
  69. package/lib/datasource/validate.js +5 -2
  70. package/lib/external-system/generator.js +12 -8
  71. package/lib/external-system/test-system-level.js +1 -1
  72. package/lib/generator/external-controller-manifest.js +3 -3
  73. package/lib/generator/external.js +7 -7
  74. package/lib/generator/helpers.js +13 -9
  75. package/lib/generator/index.js +4 -4
  76. package/lib/generator/split.js +45 -10
  77. package/lib/generator/wizard.js +9 -6
  78. package/lib/infrastructure/helpers.js +50 -35
  79. package/lib/infrastructure/index.js +39 -23
  80. package/lib/schema/env-config.yaml +19 -2
  81. package/lib/schema/external-datasource.schema.json +11 -1
  82. package/lib/utils/app-config-resolver.js +23 -1
  83. package/lib/utils/config-paths.js +48 -4
  84. package/lib/utils/credential-secrets-env.js +16 -1
  85. package/lib/utils/env-map.js +7 -3
  86. package/lib/utils/error-formatter.js +37 -0
  87. package/lib/utils/external-env-template.js +180 -0
  88. package/lib/utils/external-system-display.js +43 -0
  89. package/lib/utils/external-system-validators.js +2 -2
  90. package/lib/utils/help-builder.js +3 -5
  91. package/lib/utils/local-secrets.js +26 -3
  92. package/lib/utils/paths.js +2 -1
  93. package/lib/utils/secrets-generator.js +2 -2
  94. package/lib/utils/secrets-utils.js +4 -0
  95. package/lib/utils/secure-file-permissions.js +91 -0
  96. package/lib/utils/token-manager.js +36 -3
  97. package/lib/utils/yaml-preserve.js +59 -1
  98. package/lib/validation/env-template-auth.js +50 -2
  99. package/lib/validation/external-manifest-validator.js +8 -0
  100. package/lib/validation/validate.js +8 -0
  101. package/lib/validation/validator.js +10 -13
  102. package/package.json +5 -1
  103. package/templates/applications/dataplane/env.template +5 -1
  104. package/templates/applications/miso-controller/application.yaml +1 -1
  105. package/templates/applications/miso-controller/env.template +13 -2
  106. package/templates/external-system/env.template.hbs +22 -0
  107. package/integration/hubspot/README.md +0 -102
  108. package/integration/hubspot/env.template +0 -4
  109. package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +0 -2
  110. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +0 -5
  111. package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +0 -5
  112. /package/integration/{hubspot → hubspot-test}/companies.json +0 -0
  113. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-app-name.yaml +0 -0
  114. /package/integration/{hubspot → hubspot-test}/test-artifacts/wizard-invalid-missing-app.yaml +0 -0
  115. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-helpers.js +0 -0
  116. /package/integration/{hubspot → hubspot-test}/test-dataplane-down-tests.js +0 -0
  117. /package/integration/{hubspot → hubspot-test}/test-dataplane-down.js +0 -0
@@ -12,6 +12,7 @@ const fs = require('fs');
12
12
  const { loadExternalDataSourceSchema } = require('../utils/schema-loader');
13
13
  const { formatValidationErrors } = require('../utils/error-formatter');
14
14
  const { validateFieldReferences } = require('./field-reference-validator');
15
+ const { validateAbac } = require('./abac-validator');
15
16
 
16
17
  /**
17
18
  * Validates a datasource file against external-datasource schema
@@ -60,10 +61,12 @@ async function validateDatasourceFile(filePath) {
60
61
  }
61
62
 
62
63
  const fieldRefErrors = validateFieldReferences(parsed);
63
- if (fieldRefErrors.length > 0) {
64
+ const abacErrors = validateAbac(parsed);
65
+ const postSchemaErrors = [...fieldRefErrors, ...abacErrors];
66
+ if (postSchemaErrors.length > 0) {
64
67
  return {
65
68
  valid: false,
66
- errors: fieldRefErrors,
69
+ errors: postSchemaErrors,
67
70
  warnings: []
68
71
  };
69
72
  }
@@ -16,6 +16,7 @@ const chalk = require('chalk');
16
16
  const logger = require('../utils/logger');
17
17
  const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
18
18
  const { loadConfigFile, writeConfigFile } = require('../utils/config-format');
19
+ const { getKvPathSegmentForSecurityKey } = require('../utils/credential-secrets-env');
19
20
 
20
21
  // Register Handlebars helper for equality check
21
22
  handlebars.registerHelper('eq', (a, b) => a === b);
@@ -23,36 +24,39 @@ handlebars.registerHelper('json', (obj) => JSON.stringify(obj));
23
24
 
24
25
  /**
25
26
  * Build authentication object per schema authenticationVariablesByMethod.
26
- * Security values use kv://<systemKey>/<key> pattern.
27
+ * Security values use canonical kv://<systemKey>/<segment> paths (segment from getKvPathSegmentForSecurityKey).
27
28
  * @param {string} systemKey - External system key
28
29
  * @param {string} authType - Auth method (oauth2, aad, apikey, basic, queryParam, oidc, hmac, none)
29
30
  * @returns {{ method: string, variables: Object, security: Object }} Authentication object
30
31
  */
31
32
  function buildAuthenticationFromMethod(systemKey, authType) {
32
- const kv = (key) => `kv://${systemKey}/${key}`;
33
+ const kvPath = (securityKey) => {
34
+ const segment = getKvPathSegmentForSecurityKey(securityKey);
35
+ return segment ? `kv://${systemKey}/${segment}` : null;
36
+ };
33
37
  const method = authType || 'apikey';
34
38
  const base = 'https://api.example.com';
35
39
 
36
40
  const authMap = {
37
41
  oauth2: {
38
42
  variables: { baseUrl: base, tokenUrl: `${base}/oauth/token`, authorizationUrl: `${base}/oauth/authorize` },
39
- security: { clientId: kv('clientid'), clientSecret: kv('clientsecret') }
43
+ security: { clientId: kvPath('clientId'), clientSecret: kvPath('clientSecret') }
40
44
  },
41
45
  aad: {
42
46
  variables: { baseUrl: base, tokenUrl: 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token', tenantId: '{tenant-id}' },
43
- security: { clientId: kv('clientid'), clientSecret: kv('clientsecret') }
47
+ security: { clientId: kvPath('clientId'), clientSecret: kvPath('clientSecret') }
44
48
  },
45
49
  apikey: {
46
50
  variables: { baseUrl: base, headerName: 'X-API-Key' },
47
- security: { apiKey: kv('apikey') }
51
+ security: { apiKey: kvPath('apiKey') }
48
52
  },
49
53
  basic: {
50
54
  variables: { baseUrl: base },
51
- security: { username: kv('username'), password: kv('password') }
55
+ security: { username: kvPath('username'), password: kvPath('password') }
52
56
  },
53
57
  queryParam: {
54
58
  variables: { baseUrl: base, paramName: 'api_key' },
55
- security: { paramValue: kv('paramvalue') }
59
+ security: { paramValue: kvPath('paramValue') }
56
60
  },
57
61
  oidc: {
58
62
  variables: { openIdConfigUrl: 'https://example.com/.well-known/openid-configuration', clientId: 'app-id' },
@@ -60,7 +64,7 @@ function buildAuthenticationFromMethod(systemKey, authType) {
60
64
  },
61
65
  hmac: {
62
66
  variables: { baseUrl: base, algorithm: 'sha256', signatureHeader: 'X-Signature' },
63
- security: { signingSecret: kv('signingsecret') }
67
+ security: { signingSecret: kvPath('signingSecret') }
64
68
  },
65
69
  none: {
66
70
  variables: {},
@@ -42,7 +42,7 @@ async function runSystemLevelTest({ appName, systemKey, authConfig, dataplaneUrl
42
42
  let success = true;
43
43
 
44
44
  for (const r of rawResults) {
45
- const dsKey = r.key || r.datasourceKey;
45
+ const dsKey = r.key || r.sourceKey || r.name || r.datasourceKey;
46
46
  const dsResult = {
47
47
  key: dsKey,
48
48
  success: r.success !== false,
@@ -11,7 +11,7 @@
11
11
 
12
12
  const path = require('path');
13
13
  const { detectAppType } = require('../utils/paths');
14
- const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
14
+ const { resolveApplicationConfigPath, resolveRbacPath } = require('../utils/app-config-resolver');
15
15
  const { loadSystemFile, loadDatasourceFiles } = require('./external');
16
16
  const { loadVariables, loadRbac } = require('./helpers');
17
17
 
@@ -77,8 +77,8 @@ function extractAppMetadata(variables, appName) {
77
77
  */
78
78
  async function loadSystemWithRbac(appPath, schemaBasePath, systemFile) {
79
79
  const systemJson = await loadSystemFile(appPath, schemaBasePath, systemFile);
80
- const rbacPath = path.join(appPath, 'rbac.yaml');
81
- const rbac = loadRbac(rbacPath);
80
+ const rbacPath = resolveRbacPath(appPath);
81
+ const rbac = rbacPath ? loadRbac(rbacPath) : null;
82
82
  mergeRbacIntoSystemJson(systemJson, rbac);
83
83
  return systemJson;
84
84
  }
@@ -12,7 +12,7 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const Ajv = require('ajv');
14
14
  const { detectAppType, getDeployJsonPath } = require('../utils/paths');
15
- const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
15
+ const { resolveApplicationConfigPath, resolveRbacPath } = require('../utils/app-config-resolver');
16
16
  const { loadConfigFile } = require('../utils/config-format');
17
17
  const { loadVariables, loadRbac } = require('./helpers');
18
18
  const {
@@ -117,9 +117,9 @@ async function generateExternalSystemDeployJson(appName, appPath) {
117
117
  const systemFilePath = resolveSystemFilePath(variables, appPath, appName);
118
118
  const systemJson = await loadSystemFileContent(systemFilePath);
119
119
 
120
- // Load rbac.yaml from app directory (similar to regular apps)
121
- const rbacPath = path.join(appPath, 'rbac.yaml');
122
- const rbac = loadRbac(rbacPath);
120
+ // Load RBAC from app directory (rbac.yaml, rbac.yml, or rbac.json)
121
+ const rbacPath = resolveRbacPath(appPath);
122
+ const rbac = rbacPath ? loadRbac(rbacPath) : null;
123
123
  mergeRbacIntoSystemJson(systemJson, rbac);
124
124
 
125
125
  // Write it as <app-name>-deploy.json (consistent naming)
@@ -153,9 +153,9 @@ async function loadSystemFile(appPath, schemaBasePath, systemFileName) {
153
153
 
154
154
  const systemJson = loadConfigFile(systemFilePath);
155
155
 
156
- // Load rbac.yaml from app directory and merge if present
157
- const rbacPath = path.join(appPath, 'rbac.yaml');
158
- const rbac = loadRbac(rbacPath);
156
+ // Load RBAC from app directory (rbac.yaml, rbac.yml, or rbac.json) and merge if present
157
+ const rbacPath = resolveRbacPath(appPath);
158
+ const rbac = rbacPath ? loadRbac(rbacPath) : null;
159
159
  if (rbac) {
160
160
  if (rbac.roles && (!systemJson.roles || systemJson.roles.length === 0)) {
161
161
  systemJson.roles = rbac.roles;
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  const fs = require('fs');
12
- const yaml = require('js-yaml');
12
+ const path = require('path');
13
13
  const { loadConfigFile } = require('../utils/config-format');
14
14
 
15
15
  /**
@@ -37,21 +37,25 @@ function loadEnvTemplate(templatePath) {
37
37
  }
38
38
 
39
39
  /**
40
- * Loads rbac.yaml file if it exists
41
- * @param {string} rbacPath - Path to rbac.yaml
42
- * @returns {Object|null} Parsed RBAC configuration or null
43
- * @throws {Error} If file exists but has invalid YAML
40
+ * Loads RBAC config file (rbac.yaml, rbac.yml, or rbac.json) if it exists.
41
+ * Uses loadConfigFile so format is inferred from extension.
42
+ *
43
+ * @param {string} rbacPath - Path to RBAC file (e.g. from resolveRbacPath)
44
+ * @returns {Object|null} Parsed RBAC configuration or null if path is falsy or file does not exist
45
+ * @throws {Error} If file exists but has invalid syntax (message references actual filename, e.g. rbac.json)
44
46
  */
45
47
  function loadRbac(rbacPath) {
48
+ if (!rbacPath || typeof rbacPath !== 'string') {
49
+ return null;
50
+ }
46
51
  if (!fs.existsSync(rbacPath)) {
47
52
  return null;
48
53
  }
49
-
50
- const rbacContent = fs.readFileSync(rbacPath, 'utf8');
51
54
  try {
52
- return yaml.load(rbacContent);
55
+ return loadConfigFile(rbacPath);
53
56
  } catch (error) {
54
- throw new Error(`Invalid YAML syntax in rbac.yaml: ${error.message}`);
57
+ const basename = path.basename(rbacPath);
58
+ throw new Error(`Invalid syntax in ${basename}: ${error.message}`);
55
59
  }
56
60
  }
57
61
 
@@ -13,7 +13,7 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const _validator = require('../validation/validator');
15
15
  const builders = require('./builders');
16
- const { detectAppType, getDeployJsonPath, resolveApplicationConfigPath } = require('../utils/paths');
16
+ const { detectAppType, getDeployJsonPath, resolveApplicationConfigPath, resolveRbacPath } = require('../utils/paths');
17
17
  const { logOfflinePathWhenType } = require('../utils/cli-utils');
18
18
  const splitFunctions = require('./split');
19
19
  const { loadVariables, loadEnvTemplate, loadRbac, parseEnvironmentVariables } = require('./helpers');
@@ -39,7 +39,7 @@ const { buildEnvVarMap } = require('../utils/env-map');
39
39
  *
40
40
  * @example
41
41
  * const jsonPath = await generateDeployJson('myapp');
42
- * // Returns: './builder/myapp/myapp-deploy.json' or './integration/hubspot/application-schema.json'
42
+ * // Returns: './builder/myapp/myapp-deploy.json' or './integration/hubspot-test/application-schema.json'
43
43
  */
44
44
  /**
45
45
  * Loads configuration files for deployment generation
@@ -51,12 +51,12 @@ const { buildEnvVarMap } = require('../utils/env-map');
51
51
  function loadDeploymentConfigFiles(appPath, appType, appName) {
52
52
  const variablesPath = resolveApplicationConfigPath(appPath);
53
53
  const templatePath = path.join(appPath, 'env.template');
54
- const rbacPath = path.join(appPath, 'rbac.yaml');
54
+ const rbacPath = resolveRbacPath(appPath);
55
55
  const jsonPath = getDeployJsonPath(appName, appType, true); // Use new naming
56
56
 
57
57
  const { parsed: variables } = loadVariables(variablesPath);
58
58
  const envTemplate = loadEnvTemplate(templatePath);
59
- const rbac = loadRbac(rbacPath);
59
+ const rbac = rbacPath ? loadRbac(rbacPath) : null;
60
60
 
61
61
  return { variables, envTemplate, rbac, jsonPath };
62
62
  }
@@ -14,6 +14,7 @@ const yaml = require('js-yaml');
14
14
  const { parseImageReference } = require('./parse-image');
15
15
  const { generateReadmeFromDeployJson } = require('./split-readme');
16
16
  const { extractVariablesYaml, getExternalDatasourceFileName } = require('./split-variables');
17
+ const { generateExternalEnvTemplateContent } = require('../utils/external-env-template');
17
18
 
18
19
  /**
19
20
  * Converts configuration array back to env.template format
@@ -207,24 +208,49 @@ function mergeEnvTemplateWithExisting(existingContent, expectedByKey) {
207
208
  return updatedLines.join('\n') + (updatedLines.length > 0 ? '\n' : '');
208
209
  }
209
210
 
211
+ /**
212
+ * Builds key -> line map from env.template content (for merge when using external system template).
213
+ * @param {string} content - Full env.template content
214
+ * @returns {Map<string, string>} Key to full KEY=value line
215
+ */
216
+ function buildExpectedByKeyFromEnvContent(content) {
217
+ const expectedByKey = new Map();
218
+ if (!content || typeof content !== 'string') return expectedByKey;
219
+ const lines = content.split(/\r?\n/);
220
+ for (const line of lines) {
221
+ const trimmed = line.trim();
222
+ if (!trimmed || trimmed.startsWith('#')) continue;
223
+ const eq = trimmed.indexOf('=');
224
+ if (eq > 0) {
225
+ const key = trimmed.substring(0, eq).trim();
226
+ expectedByKey.set(key, trimmed);
227
+ }
228
+ }
229
+ return expectedByKey;
230
+ }
231
+
210
232
  /**
211
233
  * Writes env.template (merge or overwrite).
212
234
  * @param {string} outputDir - Output directory
213
235
  * @param {string} envTemplate - Default env.template content
214
- * @param {Object} options - Options (mergeEnvTemplate, configuration)
236
+ * @param {Object} options - Options (mergeEnvTemplate, configuration, expectedByKey for external)
215
237
  * @returns {Promise<string>} Path to env.template
216
238
  */
217
239
  async function writeEnvTemplateToDir(outputDir, envTemplate, options) {
218
240
  const envTemplatePath = path.join(outputDir, 'env.template');
219
241
  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);
242
+ const useMerge = options.mergeEnvTemplate && fsSync.existsSync(envTemplatePath);
243
+ if (useMerge) {
244
+ const expectedByKey = options.expectedByKey ||
245
+ (options.configuration ? buildEnvTemplateExpectedByKey(options.configuration) : new Map());
246
+ if (expectedByKey.size > 0) {
247
+ const existingContent = await fs.readFile(envTemplatePath, 'utf8');
248
+ const merged = mergeEnvTemplateWithExisting(existingContent, expectedByKey);
249
+ await writeComponentFile(envTemplatePath, merged);
250
+ return envTemplatePath;
251
+ }
227
252
  }
253
+ await writeComponentFile(envTemplatePath, envTemplate);
228
254
  return envTemplatePath;
229
255
  }
230
256
 
@@ -401,12 +427,21 @@ async function splitDeployJson(deployJsonPath, outputDir = null, splitOptions =
401
427
  normalizeDeploymentForSplit(deployment);
402
428
 
403
429
  const configArray = deployment.configuration || [];
404
- const envTemplate = extractEnvTemplate(configArray);
430
+ let envTemplate;
431
+ const writeOptions = buildSplitWriteOptions(splitOptions, configArray);
432
+ if (deployment.system && typeof deployment.system === 'object') {
433
+ envTemplate = generateExternalEnvTemplateContent(deployment.system);
434
+ if (writeOptions.mergeEnvTemplate) {
435
+ writeOptions.expectedByKey = buildExpectedByKeyFromEnvContent(envTemplate);
436
+ }
437
+ } else {
438
+ envTemplate = extractEnvTemplate(configArray);
439
+ }
440
+
405
441
  const variables = extractVariablesYaml(deployment);
406
442
  const rbac = extractRbacYaml(deployment);
407
443
  const readme = generateReadmeFromDeployJson(deployment);
408
444
 
409
- const writeOptions = buildSplitWriteOptions(splitOptions, configArray);
410
445
  const result = await writeComponentFiles(finalOutputDir, envTemplate, variables, rbac, readme, writeOptions);
411
446
  await applyExternalSystemFilesToResult(finalOutputDir, deployment, result);
412
447
  return result;
@@ -16,6 +16,7 @@ const logger = require('../utils/logger');
16
16
  const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
17
17
  const { loadConfigFile, writeConfigFile } = require('../utils/config-format');
18
18
  const { systemKeyToKvPrefix, securityKeyToVar, isValidKvPath } = require('../utils/credential-secrets-env');
19
+ const { generateExternalEnvTemplateContent } = require('../utils/external-env-template');
19
20
  const { generateReadme } = require('./wizard-readme');
20
21
 
21
22
  /**
@@ -130,10 +131,12 @@ async function generateConfigFilesForWizard(params) {
130
131
  format: format || 'yaml'
131
132
  });
132
133
 
133
- // Generate env.template with KV_* authentication variables
134
- await generateEnvTemplate(appPath, systemConfig, finalSystemKey);
135
-
134
+ // Generate env.template with Authentication and Configuration sections (Handlebars)
136
135
  const envTemplatePath = path.join(appPath, 'env.template');
136
+ const envTemplateContent = generateExternalEnvTemplateContent(systemConfig);
137
+ await fs.writeFile(envTemplatePath, envTemplateContent, 'utf8');
138
+ logger.log(chalk.green('✓ Generated env.template'));
139
+
137
140
  try {
138
141
  const secretsEnsure = require('../core/secrets-ensure');
139
142
  await secretsEnsure.ensureSecretsFromEnvTemplate(envTemplatePath, { emptyValuesForCredentials: true });
@@ -393,15 +396,15 @@ function addBaseUrlLines(lines, systemConfig) {
393
396
  }
394
397
 
395
398
  /**
396
- * Generate env.template with KV_* authentication variables
399
+ * Generate env.template with KV_* authentication variables (legacy; prefer generateExternalEnvTemplateContent).
397
400
  * @async
398
- * @function generateEnvTemplate
401
+ * @function _generateEnvTemplate
399
402
  * @param {string} appPath - Application directory path
400
403
  * @param {Object} systemConfig - System configuration (must have key for systemKey)
401
404
  * @param {string} [finalSystemKey] - Final system key for KV_ prefix (default: systemConfig.key)
402
405
  * @throws {Error} If generation fails
403
406
  */
404
- async function generateEnvTemplate(appPath, systemConfig, finalSystemKey) {
407
+ async function _generateEnvTemplate(appPath, systemConfig, finalSystemKey) {
405
408
  try {
406
409
  const envTemplatePath = path.join(appPath, 'env.template');
407
410
  const systemKey = finalSystemKey || systemConfig?.key;
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const chalk = require('chalk');
15
15
  const handlebars = require('handlebars');
16
16
  const secrets = require('../core/secrets');
17
+ const adminSecrets = require('../core/admin-secrets');
17
18
  const logger = require('../utils/logger');
18
19
  const dockerUtils = require('../utils/docker');
19
20
  const paths = require('../utils/paths');
@@ -69,19 +70,6 @@ function logVolumeResetHint(infraDir) {
69
70
  ));
70
71
  }
71
72
 
72
- /**
73
- * Apply password to admin-secrets file content (all three password keys).
74
- * @param {string} content - Current file content
75
- * @param {string} password - Password to set
76
- * @returns {string} Updated content
77
- */
78
- function applyPasswordToAdminSecretsContent(content, password) {
79
- return content
80
- .replace(/^POSTGRES_PASSWORD=.*$/m, `POSTGRES_PASSWORD=${password}`)
81
- .replace(/^PGADMIN_DEFAULT_PASSWORD=.*$/m, `PGADMIN_DEFAULT_PASSWORD=${password}`)
82
- .replace(/^REDIS_COMMANDER_PASSWORD=.*$/m, `REDIS_COMMANDER_PASSWORD=${password}`);
83
- }
84
-
85
73
  /**
86
74
  * Sync postgres-passwordKeyVault to the main secrets store (file or remote).
87
75
  * @param {string} password - Password to store
@@ -94,10 +82,42 @@ async function syncPostgresPasswordToStore(password) {
94
82
  }
95
83
  }
96
84
 
85
+ /** Default admin env keys and values when missing. */
86
+ const DEFAULT_ADMIN_OBJ = {
87
+ PGADMIN_DEFAULT_EMAIL: 'admin@aifabrix.dev',
88
+ REDIS_HOST: 'local:redis:6379:0:',
89
+ REDIS_COMMANDER_USER: 'admin'
90
+ };
91
+
92
+ /**
93
+ * Writes merged admin secrets to disk and logs/syncs as needed.
94
+ * @async
95
+ * @param {string} adminSecretsPath - Path to admin-secrets.env
96
+ * @param {Object} adminObj - Decrypted admin secrets object
97
+ * @param {string} passwordToUse - Password to set for Postgres, pgAdmin, Redis Commander
98
+ * @param {boolean} shouldOverwriteWithAdminPwd - Whether this was an explicit admin password update
99
+ */
100
+ async function applyAdminSecretsUpdate(adminSecretsPath, adminObj, passwordToUse, shouldOverwriteWithAdminPwd) {
101
+ const merged = { ...DEFAULT_ADMIN_OBJ, ...adminObj };
102
+ merged.POSTGRES_PASSWORD = passwordToUse;
103
+ merged.PGADMIN_DEFAULT_PASSWORD = passwordToUse;
104
+ merged.REDIS_COMMANDER_PASSWORD = passwordToUse;
105
+ const content = await secrets.formatAdminSecretsContent(merged);
106
+ fs.writeFileSync(adminSecretsPath, content, { mode: 0o600 });
107
+ if (shouldOverwriteWithAdminPwd) {
108
+ logger.log('Updated admin password in admin-secrets.env.');
109
+ await syncPostgresPasswordToStore(passwordToUse);
110
+ logVolumeResetHint(path.join(paths.getAifabrixHome(), getInfraDirName(0)));
111
+ } else {
112
+ logger.log('Set default admin password in admin-secrets.env for local use.');
113
+ }
114
+ }
115
+
97
116
  /**
98
117
  * Ensure admin secrets file exists and set admin password.
99
118
  * When adminPwd is provided, update POSTGRES_PASSWORD, PGADMIN_DEFAULT_PASSWORD, REDIS_COMMANDER_PASSWORD
100
119
  * in admin-secrets.env (overwrites existing values). Otherwise only backfill empty fields.
120
+ * Reads and writes using decrypted values; writes encrypted when secrets-encryption key is set.
101
121
  *
102
122
  * @async
103
123
  * @param {Object} [options] - Options
@@ -109,29 +129,25 @@ async function ensureAdminSecrets(options = {}) {
109
129
  ? options.adminPwd.trim()
110
130
  : null;
111
131
  const passwordToUse = adminPwdOverride || DEFAULT_ADMIN_PASSWORD;
112
-
113
132
  const adminSecretsPath = path.join(paths.getAifabrixHome(), 'admin-secrets.env');
133
+
114
134
  if (!fs.existsSync(adminSecretsPath)) {
115
135
  logger.log('Generating admin-secrets.env...');
116
136
  await secrets.generateAdminSecretsEnv(undefined);
137
+ return adminSecretsPath;
117
138
  }
118
- let content = fs.readFileSync(adminSecretsPath, 'utf8');
119
- const needsBackfill = /^POSTGRES_PASSWORD=\s*$/m.test(content) ||
120
- /^PGADMIN_DEFAULT_PASSWORD=\s*$/m.test(content) ||
121
- /^REDIS_COMMANDER_PASSWORD=\s*$/m.test(content);
139
+
140
+ const adminObj = await adminSecrets.readAndDecryptAdminSecrets(adminSecretsPath);
141
+ const needsBackfill = !(adminObj.POSTGRES_PASSWORD && adminObj.POSTGRES_PASSWORD.trim()) ||
142
+ !(adminObj.PGADMIN_DEFAULT_PASSWORD && adminObj.PGADMIN_DEFAULT_PASSWORD.trim()) ||
143
+ !(adminObj.REDIS_COMMANDER_PASSWORD && adminObj.REDIS_COMMANDER_PASSWORD.trim());
122
144
  const shouldOverwriteWithAdminPwd = adminPwdOverride !== null;
123
145
 
124
- if (shouldOverwriteWithAdminPwd) {
125
- content = applyPasswordToAdminSecretsContent(content, passwordToUse);
126
- fs.writeFileSync(adminSecretsPath, content, { mode: 0o600 });
127
- logger.log('Updated admin password in admin-secrets.env.');
128
- await syncPostgresPasswordToStore(passwordToUse);
129
- logVolumeResetHint(path.join(paths.getAifabrixHome(), getInfraDirName(0)));
130
- } else if (needsBackfill) {
131
- content = applyPasswordToAdminSecretsContent(content, passwordToUse);
132
- fs.writeFileSync(adminSecretsPath, content, { mode: 0o600 });
133
- logger.log('Set default admin password in admin-secrets.env for local use.');
146
+ if (!shouldOverwriteWithAdminPwd && !needsBackfill) {
147
+ return adminSecretsPath;
134
148
  }
149
+
150
+ await applyAdminSecretsUpdate(adminSecretsPath, adminObj, passwordToUse, shouldOverwriteWithAdminPwd);
135
151
  return adminSecretsPath;
136
152
  }
137
153
 
@@ -243,12 +259,13 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "miso" -c "GRANT AL
243
259
  }
244
260
 
245
261
  /**
246
- * Prepare infrastructure directory and extract postgres password
262
+ * Prepare infrastructure directory and extract postgres password from decrypted admin secrets.
263
+ * @async
247
264
  * @param {string} devId - Developer ID
248
265
  * @param {string} adminSecretsPath - Path to admin secrets file
249
- * @returns {Object} Object with infraDir and postgresPassword
266
+ * @returns {Promise<Object>} Object with infraDir and postgresPassword
250
267
  */
251
- function prepareInfraDirectory(devId, adminSecretsPath) {
268
+ async function prepareInfraDirectory(devId, adminSecretsPath) {
252
269
  const aifabrixDir = paths.getAifabrixHome();
253
270
  const infraDirName = getInfraDirName(devId);
254
271
  const infraDir = path.join(aifabrixDir, infraDirName);
@@ -265,10 +282,8 @@ function prepareInfraDirectory(devId, adminSecretsPath) {
265
282
  }
266
283
  }
267
284
 
268
- const adminSecretsContent = fs.readFileSync(adminSecretsPath, 'utf8');
269
- const postgresPasswordMatch = adminSecretsContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
270
- const raw = postgresPasswordMatch ? postgresPasswordMatch[1] : '';
271
- const postgresPassword = (raw && raw.trim()) || DEFAULT_ADMIN_PASSWORD;
285
+ const adminObj = await adminSecrets.readAndDecryptAdminSecrets(adminSecretsPath);
286
+ const postgresPassword = (adminObj.POSTGRES_PASSWORD && adminObj.POSTGRES_PASSWORD.trim()) || DEFAULT_ADMIN_PASSWORD;
272
287
  generatePgAdminConfig(infraDir, postgresPassword);
273
288
 
274
289
  return { infraDir, postgresPassword };
@@ -37,8 +37,34 @@ const {
37
37
  startDockerServicesAndConfigure,
38
38
  checkInfraHealth
39
39
  } = require('./services');
40
+ const adminSecrets = require('../core/admin-secrets');
40
41
  // Lazy require to avoid circular dependency: infra -> app/down -> run-helpers -> infra
41
42
 
43
+ /**
44
+ * Runs a callback with a temporary .env.run file in infraDir (created from admin-secrets).
45
+ * Removes the file in a finally block.
46
+ * @async
47
+ * @param {string} infraDir - Infrastructure directory path
48
+ * @param {string} adminSecretsPath - Path to admin-secrets.env
49
+ * @param {function(string): Promise<void>} fn - Callback receiving runEnvPath
50
+ * @returns {Promise<void>}
51
+ */
52
+ async function withRunEnv(infraDir, adminSecretsPath, fn) {
53
+ const runEnvPath = path.join(infraDir, '.env.run');
54
+ try {
55
+ const adminObj = await adminSecrets.readAndDecryptAdminSecrets(adminSecretsPath);
56
+ const content = adminSecrets.envObjectToContent(adminObj);
57
+ fs.writeFileSync(runEnvPath, content, { mode: 0o600 });
58
+ await fn(runEnvPath);
59
+ } finally {
60
+ try {
61
+ if (fs.existsSync(runEnvPath)) fs.unlinkSync(runEnvPath);
62
+ } catch {
63
+ // Ignore unlink errors
64
+ }
65
+ }
66
+ }
67
+
42
68
  /**
43
69
  * Prepares infrastructure environment
44
70
  * Ensures infra secrets exist, then admin-secrets.env, then miso init script.
@@ -65,7 +91,7 @@ async function prepareInfrastructureEnvironment(developerId, options = {}) {
65
91
  }
66
92
 
67
93
  // Prepare infrastructure directory
68
- const { infraDir } = prepareInfraDirectory(devId, adminSecretsPath);
94
+ const { infraDir } = await prepareInfraDirectory(devId, adminSecretsPath);
69
95
  await ensureMisoInitScript(infraDir);
70
96
 
71
97
  return { devId, idNum, ports, templatePath, infraDir, adminSecretsPath };
@@ -174,8 +200,7 @@ async function removeAppVolumes(appNames, devId) {
174
200
  async function stopInfra() {
175
201
  const devId = await config.getDeveloperId();
176
202
  const aifabrixDir = paths.getAifabrixHome();
177
- const infraDirName = getInfraDirName(devId);
178
- const infraDir = path.join(aifabrixDir, infraDirName);
203
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
179
204
  const composePath = path.join(infraDir, 'compose.yaml');
180
205
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
181
206
 
@@ -184,17 +209,15 @@ async function stopInfra() {
184
209
  return;
185
210
  }
186
211
 
187
- try {
212
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
188
213
  logger.log('Stopping application containers on the same network...');
189
214
  await stopAllAppContainers(devId);
190
215
  logger.log('Stopping infrastructure services...');
191
216
  const projectName = getInfraProjectName(devId);
192
217
  const composeCmd = await dockerUtils.getComposeCommand();
193
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down`, { cwd: infraDir });
218
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" down`, { cwd: infraDir });
194
219
  logger.log('Infrastructure services stopped');
195
- } finally {
196
- // Keep the compose file for future use
197
- }
220
+ });
198
221
  }
199
222
 
200
223
  /**
@@ -240,8 +263,7 @@ async function stopAllAppContainersAndVolumes(devId) {
240
263
  async function stopInfraWithVolumes() {
241
264
  const devId = await config.getDeveloperId();
242
265
  const aifabrixDir = paths.getAifabrixHome();
243
- const infraDirName = getInfraDirName(devId);
244
- const infraDir = path.join(aifabrixDir, infraDirName);
266
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
245
267
  const composePath = path.join(infraDir, 'compose.yaml');
246
268
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
247
269
 
@@ -250,17 +272,15 @@ async function stopInfraWithVolumes() {
250
272
  return;
251
273
  }
252
274
 
253
- try {
275
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
254
276
  logger.log('Stopping application containers on the same network...');
255
277
  await stopAllAppContainersAndVolumes(devId);
256
278
  logger.log('Stopping infrastructure services and removing all data...');
257
279
  const projectName = getInfraProjectName(devId);
258
280
  const composeCmd = await dockerUtils.getComposeCommand();
259
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down -v`, { cwd: infraDir });
281
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" down -v`, { cwd: infraDir });
260
282
  logger.log('Infrastructure services stopped and all data removed');
261
- } finally {
262
- // Keep the compose file for future use
263
- }
283
+ });
264
284
  }
265
285
 
266
286
  /**
@@ -281,7 +301,6 @@ async function restartService(serviceName) {
281
301
  if (!serviceName || typeof serviceName !== 'string') {
282
302
  throw new Error('Service name is required and must be a string');
283
303
  }
284
-
285
304
  const validServices = ['postgres', 'redis', 'pgadmin', 'redis-commander', 'traefik'];
286
305
  if (!validServices.includes(serviceName)) {
287
306
  throw new Error(`Invalid service name. Must be one of: ${validServices.join(', ')}`);
@@ -289,8 +308,7 @@ async function restartService(serviceName) {
289
308
 
290
309
  const devId = await config.getDeveloperId();
291
310
  const aifabrixDir = paths.getAifabrixHome();
292
- const infraDirName = getInfraDirName(devId);
293
- const infraDir = path.join(aifabrixDir, infraDirName);
311
+ const infraDir = path.join(aifabrixDir, getInfraDirName(devId));
294
312
  const composePath = path.join(infraDir, 'compose.yaml');
295
313
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
296
314
 
@@ -298,15 +316,13 @@ async function restartService(serviceName) {
298
316
  throw new Error('Infrastructure not properly configured');
299
317
  }
300
318
 
301
- try {
319
+ await withRunEnv(infraDir, adminSecretsPath, async(runEnvPath) => {
302
320
  logger.log(`Restarting ${serviceName} service...`);
303
321
  const projectName = getInfraProjectName(devId);
304
322
  const composeCmd = await dockerUtils.getComposeCommand();
305
- await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" restart ${serviceName}`, { cwd: infraDir });
323
+ await execAsyncWithCwd(`${composeCmd} -f "${composePath}" -p ${projectName} --env-file "${runEnvPath}" restart ${serviceName}`, { cwd: infraDir });
306
324
  logger.log(`${serviceName} service restarted successfully`);
307
- } finally {
308
- // Keep the compose file for future use
309
- }
325
+ });
310
326
  }
311
327
 
312
328
  // Re-export status helper functions