@aifabrix/builder 2.40.2 → 2.41.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 (103) hide show
  1. package/README.md +6 -4
  2. package/integration/hubspot/test.js +1 -1
  3. package/lib/api/credential.api.js +40 -0
  4. package/lib/api/dev.api.js +423 -0
  5. package/lib/api/types/credential.types.js +23 -0
  6. package/lib/api/types/dev.types.js +140 -0
  7. package/lib/app/config.js +21 -0
  8. package/lib/app/down.js +2 -1
  9. package/lib/app/index.js +9 -0
  10. package/lib/app/push.js +36 -12
  11. package/lib/app/readme.js +1 -3
  12. package/lib/app/run-env-compose.js +201 -0
  13. package/lib/app/run-helpers.js +121 -118
  14. package/lib/app/run.js +148 -28
  15. package/lib/app/show.js +5 -2
  16. package/lib/build/index.js +11 -3
  17. package/lib/cli/setup-app.js +140 -14
  18. package/lib/cli/setup-dev.js +180 -17
  19. package/lib/cli/setup-environment.js +4 -2
  20. package/lib/cli/setup-external-system.js +71 -21
  21. package/lib/cli/setup-infra.js +29 -2
  22. package/lib/cli/setup-secrets.js +52 -5
  23. package/lib/cli/setup-utility.js +12 -3
  24. package/lib/commands/app-install.js +172 -0
  25. package/lib/commands/app-shell.js +75 -0
  26. package/lib/commands/app-test.js +282 -0
  27. package/lib/commands/app.js +1 -1
  28. package/lib/commands/dev-cli-handlers.js +141 -0
  29. package/lib/commands/dev-down.js +114 -0
  30. package/lib/commands/dev-init.js +309 -0
  31. package/lib/commands/secrets-list.js +118 -0
  32. package/lib/commands/secrets-remove.js +97 -0
  33. package/lib/commands/secrets-set.js +30 -17
  34. package/lib/commands/secrets-validate.js +50 -0
  35. package/lib/commands/up-dataplane.js +2 -2
  36. package/lib/commands/up-miso.js +0 -25
  37. package/lib/commands/upload.js +26 -1
  38. package/lib/core/admin-secrets.js +96 -0
  39. package/lib/core/secrets-ensure.js +378 -0
  40. package/lib/core/secrets-env-write.js +157 -0
  41. package/lib/core/secrets.js +147 -81
  42. package/lib/datasource/field-reference-validator.js +91 -0
  43. package/lib/datasource/validate.js +21 -3
  44. package/lib/deployment/environment-config.js +137 -0
  45. package/lib/deployment/environment.js +21 -98
  46. package/lib/deployment/push.js +32 -2
  47. package/lib/external-system/download.js +7 -0
  48. package/lib/external-system/test-auth.js +7 -3
  49. package/lib/external-system/test.js +5 -1
  50. package/lib/generator/index.js +174 -25
  51. package/lib/generator/wizard.js +8 -0
  52. package/lib/infrastructure/helpers.js +103 -20
  53. package/lib/infrastructure/index.js +88 -10
  54. package/lib/infrastructure/services.js +70 -15
  55. package/lib/schema/application-schema.json +24 -3
  56. package/lib/schema/external-system.schema.json +435 -413
  57. package/lib/utils/api.js +3 -3
  58. package/lib/utils/app-register-auth.js +25 -3
  59. package/lib/utils/cli-utils.js +20 -0
  60. package/lib/utils/compose-generator.js +76 -75
  61. package/lib/utils/compose-handlebars-helpers.js +43 -0
  62. package/lib/utils/compose-vector-helper.js +18 -0
  63. package/lib/utils/config-paths.js +127 -2
  64. package/lib/utils/credential-secrets-env.js +267 -0
  65. package/lib/utils/dev-cert-helper.js +122 -0
  66. package/lib/utils/device-code-helpers.js +224 -0
  67. package/lib/utils/device-code.js +37 -336
  68. package/lib/utils/docker-build.js +40 -8
  69. package/lib/utils/env-copy.js +83 -13
  70. package/lib/utils/env-map.js +35 -5
  71. package/lib/utils/env-template.js +6 -5
  72. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  73. package/lib/utils/help-builder.js +15 -2
  74. package/lib/utils/infra-status.js +30 -1
  75. package/lib/utils/local-secrets.js +7 -52
  76. package/lib/utils/mutagen-install.js +195 -0
  77. package/lib/utils/mutagen.js +146 -0
  78. package/lib/utils/paths.js +43 -33
  79. package/lib/utils/port-resolver.js +28 -16
  80. package/lib/utils/remote-dev-auth.js +38 -0
  81. package/lib/utils/remote-docker-env.js +43 -0
  82. package/lib/utils/remote-secrets-loader.js +60 -0
  83. package/lib/utils/secrets-generator.js +94 -6
  84. package/lib/utils/secrets-helpers.js +33 -25
  85. package/lib/utils/secrets-path.js +2 -2
  86. package/lib/utils/secrets-utils.js +52 -1
  87. package/lib/utils/secrets-validation.js +84 -0
  88. package/lib/utils/ssh-key-helper.js +116 -0
  89. package/lib/utils/token-manager-messages.js +90 -0
  90. package/lib/utils/token-manager.js +5 -4
  91. package/lib/utils/variable-transformer.js +3 -3
  92. package/lib/validation/validator.js +65 -0
  93. package/package.json +2 -2
  94. package/scripts/install-local.js +34 -15
  95. package/templates/README.md +0 -1
  96. package/templates/applications/README.md.hbs +4 -4
  97. package/templates/applications/dataplane/application.yaml +5 -4
  98. package/templates/applications/dataplane/env.template +12 -7
  99. package/templates/applications/keycloak/env.template +2 -0
  100. package/templates/applications/miso-controller/application.yaml +1 -0
  101. package/templates/applications/miso-controller/env.template +11 -9
  102. package/templates/python/docker-compose.hbs +49 -23
  103. package/templates/typescript/docker-compose.hbs +48 -22
@@ -158,14 +158,8 @@ function getFallbackProjectRoot() {
158
158
  return path.resolve(__dirname, '..', '..');
159
159
  }
160
160
 
161
- /**
162
- * Gets the project root directory by finding package.json
163
- * Works reliably in all environments including Jest tests and CI
164
- * @returns {string} Absolute path to project root
165
- */
166
161
  /**
167
162
  * Checks if global PROJECT_ROOT is valid
168
- * @function checkGlobalProjectRoot
169
163
  * @returns {string|null} Valid global root or null
170
164
  */
171
165
  function checkGlobalProjectRoot() {
@@ -195,39 +189,29 @@ function checkGlobalProjectRoot() {
195
189
 
196
190
  /**
197
191
  * Tries different strategies to find project root
198
- * @function tryFindProjectRoot
199
192
  * @returns {string} Found project root
200
193
  */
201
194
  function tryFindProjectRoot() {
202
- // Strategy 1: Check global.PROJECT_ROOT
203
195
  const globalRoot = checkGlobalProjectRoot();
204
196
  if (globalRoot) {
205
197
  cachedProjectRoot = globalRoot;
206
198
  return cachedProjectRoot;
207
199
  }
208
-
209
- // Strategy 2: Walk up from __dirname
210
200
  const foundRoot = findProjectRootByWalkingUp(__dirname);
211
201
  if (foundRoot && hasPackageJson(foundRoot)) {
212
202
  cachedProjectRoot = foundRoot;
213
203
  return cachedProjectRoot;
214
204
  }
215
-
216
- // Strategy 3: Try process.cwd()
217
205
  const cwdRoot = findProjectRootFromCwd();
218
206
  if (cwdRoot && hasPackageJson(cwdRoot)) {
219
207
  cachedProjectRoot = cwdRoot;
220
208
  return cachedProjectRoot;
221
209
  }
222
-
223
- // Strategy 4: Fallback
224
210
  const fallbackRoot = getFallbackProjectRoot();
225
211
  if (hasPackageJson(fallbackRoot)) {
226
212
  cachedProjectRoot = fallbackRoot;
227
213
  return cachedProjectRoot;
228
214
  }
229
-
230
- // Last resort
231
215
  cachedProjectRoot = fallbackRoot;
232
216
  return cachedProjectRoot;
233
217
  }
@@ -242,10 +226,7 @@ function getProjectRoot() {
242
226
  }
243
227
 
244
228
  /**
245
- * Returns the applications base directory for a developer.
246
- * Dev 0: <home>/applications
247
- * Dev > 0: <home>/applications-dev-{id}
248
- *
229
+ * Returns the applications base directory. Dev 0: <home>/applications; Dev > 0: <home>/applications-dev-{id}
249
230
  * @param {number|string} developerId - Developer ID
250
231
  * @returns {string} Absolute path to applications base directory
251
232
  */
@@ -259,19 +240,13 @@ function getApplicationsBaseDir(developerId) {
259
240
  }
260
241
 
261
242
  /**
262
- * Returns the developer-specific application directory.
263
- * Dev 0: points to applications/ (root)
264
- * Dev > 0: <home>/applications-dev-{id} (root)
265
- *
243
+ * Returns the developer-specific application directory. Dev 0: applications/; Dev > 0: applications-dev-{id}
266
244
  * @param {string} appName - Application name
267
245
  * @param {number|string} developerId - Developer ID
268
- * @returns {string} Absolute path to developer-specific app directory
246
+ * @returns {string} Developer-specific app directory (root)
269
247
  */
270
248
  function getDevDirectory(appName, developerId) {
271
249
  const baseDir = getApplicationsBaseDir(developerId);
272
- // All files should be generated at the root of the applications folder
273
- // Dev 0: <home>/applications
274
- // Dev > 0: <home>/applications-dev-{id}
275
250
  return baseDir;
276
251
  }
277
252
 
@@ -292,7 +267,6 @@ function getAppPath(appName, appType) {
292
267
 
293
268
  /**
294
269
  * Base directory for integration/builder: project root when cwd is inside project, else cwd.
295
- * So deploy works when run from integration/<app> (e.g. node deploy.js), and tests using temp dirs still work.
296
270
  * @returns {string} Directory to resolve integration/ and builder/ from
297
271
  */
298
272
  function getIntegrationBuilderBaseDir() {
@@ -307,7 +281,6 @@ function getIntegrationBuilderBaseDir() {
307
281
 
308
282
  /**
309
283
  * Gets the integration folder path for external systems.
310
- * Uses project root when cwd is inside project so deploy works when run from integration/<app> (e.g. node deploy.js).
311
284
  * @param {string} appName - Application name
312
285
  * @returns {string} Absolute path to integration directory
313
286
  */
@@ -320,9 +293,21 @@ function getIntegrationPath(appName) {
320
293
  }
321
294
 
322
295
  /**
323
- * Gets the builder folder path for regular applications.
324
- * When AIFABRIX_BUILDER_DIR is set (e.g. by up-miso/up-dataplane from config aifabrix-env-config),
325
- * uses that as builder root; otherwise uses project root so deploy works when run from integration/<app>.
296
+ * Resolves build.context from application.yaml to an absolute path.
297
+ * Used as the canonical app code directory for local mount and (when remote) Mutagen local path.
298
+ *
299
+ * @param {string} configDir - Directory containing the application config (e.g. builder/<appKey>/)
300
+ * @param {string} [buildContext='.'] - build.context value (relative to configDir)
301
+ * @returns {string} Absolute path to the app code directory
302
+ */
303
+ function resolveBuildContext(configDir, buildContext) {
304
+ const dir = (configDir && typeof configDir === 'string') ? configDir : '';
305
+ const ctx = (buildContext && typeof buildContext === 'string') ? buildContext : '.';
306
+ return path.resolve(dir, ctx);
307
+ }
308
+
309
+ /**
310
+ * Gets the builder folder path. Uses AIFABRIX_BUILDER_DIR when set, else project root.
326
311
  * @param {string} appName - Application name
327
312
  * @returns {string} Absolute path to builder directory
328
313
  */
@@ -462,6 +447,29 @@ async function detectAppType(appName, _options = {}) {
462
447
  if (builderResult) return builderResult;
463
448
  throw new Error(`App '${appName}' not found in integration/${appName} or builder/${appName}`);
464
449
  }
450
+
451
+ /**
452
+ * Resolve-specific app path: prefer integration + env.template only (env-only mode).
453
+ * If integration/<appName>/env.template exists, use that directory without requiring application.yaml.
454
+ * Otherwise fall back to detectAppType (integration or builder with full config).
455
+ *
456
+ * @param {string} appName - Application name
457
+ * @returns {Promise<{appPath: string, envOnly: boolean}>} appPath and envOnly (true when only env.template is used)
458
+ * @throws {Error} When app not found in integration or builder
459
+ */
460
+ async function getResolveAppPath(appName) {
461
+ if (!appName || typeof appName !== 'string') {
462
+ throw new Error('App name is required and must be a string');
463
+ }
464
+ const integrationPath = getIntegrationPath(appName);
465
+ const envTemplatePath = path.join(integrationPath, 'env.template');
466
+ if (fs.existsSync(integrationPath) && fs.existsSync(envTemplatePath)) {
467
+ return { appPath: integrationPath, envOnly: true };
468
+ }
469
+ const result = await detectAppType(appName);
470
+ return { appPath: result.appPath, envOnly: false };
471
+ }
472
+
465
473
  module.exports = {
466
474
  getAifabrixHome,
467
475
  getConfigDirForPaths,
@@ -471,9 +479,11 @@ module.exports = {
471
479
  getProjectRoot,
472
480
  getIntegrationPath,
473
481
  getBuilderPath,
482
+ resolveBuildContext,
474
483
  getDeployJsonPath,
475
484
  resolveApplicationConfigPath,
476
485
  detectAppType,
486
+ getResolveAppPath,
477
487
  clearProjectRootCache
478
488
  };
479
489
 
@@ -5,7 +5,7 @@
5
5
  * Use getContainerPort for container/Docker/deployment/registration; use getLocalPort
6
6
  * for local .env and dev-id–adjusted host port.
7
7
  *
8
- * @fileoverview Port resolution from variables (port, build.containerPort, build.localPort)
8
+ * @fileoverview Port resolution from variables (port, build.containerPort)
9
9
  * @author AI Fabrix Team
10
10
  * @version 2.0.0
11
11
  */
@@ -17,8 +17,8 @@ const yaml = require('js-yaml');
17
17
 
18
18
  /**
19
19
  * Resolve container port from variables object.
20
- * Precedence: build.containerPort → port → defaultPort.
21
- * Used for: Dockerfile, container .env PORT, compose, deployment, app register, variable-transformer, builders, secrets-utils.
20
+ * Precedence: build.containerPort (if set and non-empty) → port (main port) → defaultPort.
21
+ * When containerPort is empty or missing, main port is used (e.g. keycloak 8082:8080 vs miso 3000:3000).
22
22
  *
23
23
  * @param {Object} variables - Parsed application config (or subset with build, port)
24
24
  * @param {number} [defaultPort=3000] - Default when neither build.containerPort nor port is set
@@ -26,13 +26,19 @@ const yaml = require('js-yaml');
26
26
  */
27
27
  function getContainerPort(variables, defaultPort = 3000) {
28
28
  const v = variables || {};
29
- return v.build?.containerPort ?? v.port ?? defaultPort;
29
+ const containerPort = v.build?.containerPort;
30
+ const useMain = containerPort === undefined || containerPort === null ||
31
+ (typeof containerPort === 'string' && containerPort.trim() === '');
32
+ if (!useMain && typeof containerPort === 'number' && containerPort > 0) {
33
+ return containerPort;
34
+ }
35
+ return v.port ?? defaultPort;
30
36
  }
31
37
 
32
38
  /**
33
39
  * Resolve local (development) port from variables object.
34
- * Precedence: build.localPort (if number and > 0) → port → defaultPort.
35
- * Used for: env-copy, env-ports, and as base for getLocalPortFromPath (secrets-helpers).
40
+ * Precedence: build.localPort (when positive integer) → port → defaultPort.
41
+ * Used for env-copy, env-ports, getLocalPortFromPath, run compose host port.
36
42
  *
37
43
  * @param {Object} variables - Parsed application config
38
44
  * @param {number} [defaultPort=3000] - Default when neither build.localPort nor port is set
@@ -40,9 +46,9 @@ function getContainerPort(variables, defaultPort = 3000) {
40
46
  */
41
47
  function getLocalPort(variables, defaultPort = 3000) {
42
48
  const v = variables || {};
43
- const local = v.build?.localPort;
44
- if (typeof local === 'number' && local > 0) {
45
- return local;
49
+ const localPort = v.build?.localPort;
50
+ if (typeof localPort === 'number' && localPort > 0) {
51
+ return localPort;
46
52
  }
47
53
  return v.port ?? defaultPort;
48
54
  }
@@ -67,7 +73,7 @@ function loadVariablesFromPath(variablesPath) {
67
73
 
68
74
  /**
69
75
  * Resolve container port from application config path.
70
- * Returns null when file is missing or neither build.containerPort nor port is set (for chaining with other sources).
76
+ * When containerPort is empty or missing, returns main port. Null only when file missing or no port set.
71
77
  *
72
78
  * @param {string} variablesPath - Path to application config
73
79
  * @returns {number|null} Container port or null
@@ -77,14 +83,20 @@ function getContainerPortFromPath(variablesPath) {
77
83
  if (!v) {
78
84
  return null;
79
85
  }
80
- const p = v.build?.containerPort ?? v.port;
86
+ const containerPort = v.build?.containerPort;
87
+ const useMain = containerPort === undefined || containerPort === null ||
88
+ (typeof containerPort === 'string' && containerPort.trim() === '');
89
+ if (!useMain && typeof containerPort === 'number' && containerPort > 0) {
90
+ return containerPort;
91
+ }
92
+ const p = v.port;
81
93
  return (p !== undefined && p !== null) ? p : null;
82
94
  }
83
95
 
84
96
  /**
85
97
  * Resolve local port from application config path.
86
- * Matches legacy getPortFromVariablesFile: build.localPort (if number and > 0) else variables.port or null.
87
- * Returns null when file is missing or neither is set (for calculateAppPort chain).
98
+ * Same rule as getLocalPort: build.localPort (when positive integer) port.
99
+ * Returns null when file is missing or neither localPort nor port is set.
88
100
  *
89
101
  * @param {string} variablesPath - Path to application config
90
102
  * @returns {number|null} Local port or null
@@ -94,9 +106,9 @@ function getLocalPortFromPath(variablesPath) {
94
106
  if (!v) {
95
107
  return null;
96
108
  }
97
- const local = v.build?.localPort;
98
- if (typeof local === 'number' && local > 0) {
99
- return local;
109
+ const localPort = v.build?.localPort;
110
+ if (typeof localPort === 'number' && localPort > 0) {
111
+ return localPort;
100
112
  }
101
113
  const p = v.port;
102
114
  return (p !== undefined && p !== null) ? p : null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @fileoverview Resolve Builder Server URL and client cert for cert-authenticated dev API calls
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const config = require('../core/config');
8
+ const { getCertDir, readClientCertPem } = require('./dev-cert-helper');
9
+ const { getConfigDirForPaths } = require('./paths');
10
+
11
+ /**
12
+ * Check if a string is an http(s) URL (for aifabrix-secrets remote mode).
13
+ * @param {string} value - Config value
14
+ * @returns {boolean}
15
+ */
16
+ function isRemoteSecretsUrl(value) {
17
+ return typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'));
18
+ }
19
+
20
+ /**
21
+ * Get Builder Server URL and client cert PEM when remote is configured; otherwise null.
22
+ * Use for cert-authenticated dev API calls (settings, users, ssh-keys, secrets).
23
+ * @returns {Promise<{ serverUrl: string, clientCertPem: string }|null>}
24
+ */
25
+ async function getRemoteDevAuth() {
26
+ const serverUrl = await config.getRemoteServer();
27
+ if (!serverUrl) return null;
28
+ const devId = await config.getDeveloperId();
29
+ const certDir = getCertDir(getConfigDirForPaths(), devId);
30
+ const clientCertPem = readClientCertPem(certDir);
31
+ if (!clientCertPem) return null;
32
+ return { serverUrl, clientCertPem };
33
+ }
34
+
35
+ module.exports = {
36
+ isRemoteSecretsUrl,
37
+ getRemoteDevAuth
38
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Remote Docker environment – DOCKER_HOST and TLS cert path when docker-endpoint is set.
3
+ *
4
+ * @fileoverview Env overlay for Docker CLI when using remote Builder Server
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const path = require('path');
10
+ const config = require('../core/config');
11
+ const { getCertDir } = require('./dev-cert-helper');
12
+ const { getConfigDirForPaths } = require('./paths');
13
+
14
+ /**
15
+ * If remote Docker is configured (docker-endpoint + cert.pem, key.pem, and ca.pem present),
16
+ * returns env vars for Docker CLI: DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.
17
+ * Docker requires ca.pem in DOCKER_CERT_PATH for TLS; if it is missing we return {} so
18
+ * the CLI uses local Docker and avoids "open ca.pem: no such file or directory".
19
+ *
20
+ * @returns {Promise<Object>} Env overlay (may be empty)
21
+ */
22
+ async function getRemoteDockerEnv() {
23
+ const endpoint = await config.getDockerEndpoint();
24
+ if (!endpoint || typeof endpoint !== 'string' || !endpoint.trim()) {
25
+ return {};
26
+ }
27
+ const devId = await config.getDeveloperId();
28
+ const certDir = getCertDir(getConfigDirForPaths(), devId);
29
+ const certPath = path.join(certDir, 'cert.pem');
30
+ const keyPath = path.join(certDir, 'key.pem');
31
+ const caPath = path.join(certDir, 'ca.pem');
32
+ const fs = require('fs');
33
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath) || !fs.existsSync(caPath)) {
34
+ return {};
35
+ }
36
+ return {
37
+ DOCKER_HOST: endpoint.trim(),
38
+ DOCKER_TLS_VERIFY: '1',
39
+ DOCKER_CERT_PATH: certDir
40
+ };
41
+ }
42
+
43
+ module.exports = { getRemoteDockerEnv };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Load shared secrets from Builder Server when aifabrix-secrets is an http(s) URL.
3
+ * Used for .env resolution only; values are never persisted to disk.
4
+ *
5
+ * @fileoverview Remote shared secrets loader for .env generation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const config = require('../core/config');
11
+
12
+ /**
13
+ * Fetches shared secrets from Builder Server when aifabrix-secrets is an http(s) URL.
14
+ * @returns {Promise<Object|null>} Key-value secrets from API or null
15
+ */
16
+ async function loadRemoteSharedSecrets() {
17
+ const { isRemoteSecretsUrl, getRemoteDevAuth } = require('./remote-dev-auth');
18
+ const devApi = require('../api/dev.api');
19
+ const configSecretsPath = await config.getSecretsPath();
20
+ if (!configSecretsPath || !isRemoteSecretsUrl(configSecretsPath)) {
21
+ return null;
22
+ }
23
+ const auth = await getRemoteDevAuth();
24
+ if (!auth) return null;
25
+ try {
26
+ const items = await devApi.listSecrets(auth.serverUrl, auth.clientCertPem);
27
+ if (!Array.isArray(items)) return null;
28
+ const obj = {};
29
+ for (const item of items) {
30
+ if (item && typeof item.name === 'string' && item.value !== undefined) {
31
+ obj[item.name] = String(item.value);
32
+ }
33
+ }
34
+ return obj;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Merges remote shared secrets with user secrets. User wins on same key.
42
+ * @param {Object} userSecrets - User secrets object
43
+ * @param {Object} remoteSecrets - Remote API secrets (key-value)
44
+ * @returns {Object} Merged object
45
+ */
46
+ function mergeUserWithRemoteSecrets(userSecrets, remoteSecrets) {
47
+ const merged = { ...userSecrets };
48
+ if (!remoteSecrets || typeof remoteSecrets !== 'object') return merged;
49
+ for (const key of Object.keys(remoteSecrets)) {
50
+ if (!(key in merged) || merged[key] === undefined || merged[key] === null || merged[key] === '') {
51
+ merged[key] = remoteSecrets[key];
52
+ }
53
+ }
54
+ return merged;
55
+ }
56
+
57
+ module.exports = {
58
+ loadRemoteSharedSecrets,
59
+ mergeUserWithRemoteSecrets
60
+ };
@@ -17,6 +17,54 @@ const crypto = require('crypto');
17
17
  const logger = require('./logger');
18
18
  const pathsUtil = require('./paths');
19
19
 
20
+ /**
21
+ * Parse key-value pairs from YAML-like lines (last occurrence wins per key).
22
+ * @param {string} content - Raw YAML content
23
+ * @returns {Object} Parsed object
24
+ */
25
+ function parseYamlKeyValueLines(content) {
26
+ const result = {};
27
+ const keyValueRe = /^\s*([^#:]+):\s*(.*)$/;
28
+ const lines = content.split(/\r?\n/);
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
32
+ const m = line.match(keyValueRe);
33
+ if (!m) continue;
34
+ const key = m[1].trim();
35
+ let value = m[2].trim();
36
+ if ((value.startsWith('\'') && value.endsWith('\'')) || (value.startsWith('"') && value.endsWith('"'))) {
37
+ value = value.slice(1, -1).replace(/\\'/g, '\'').replace(/\\"/g, '"');
38
+ }
39
+ result[key] = value;
40
+ }
41
+ return result;
42
+ }
43
+
44
+ /**
45
+ * Parse YAML content tolerating duplicate keys (last occurrence wins).
46
+ * Use for secrets files that may have been appended to repeatedly.
47
+ * Tries yaml.load first; on "duplicate key" error falls back to line-by-line parse.
48
+ *
49
+ * @param {string} content - Raw YAML content
50
+ * @returns {Object} Parsed object (last value wins for duplicate keys)
51
+ */
52
+ function loadYamlTolerantOfDuplicateKeys(content) {
53
+ if (!content || typeof content !== 'string') {
54
+ return {};
55
+ }
56
+ try {
57
+ const parsed = yaml.load(content);
58
+ return parsed && typeof parsed === 'object' ? parsed : {};
59
+ } catch (err) {
60
+ const msg = err.message || '';
61
+ if (!msg.includes('duplicate') && !msg.includes('duplicated mapping')) {
62
+ throw err;
63
+ }
64
+ }
65
+ return parseYamlKeyValueLines(content);
66
+ }
67
+
20
68
  /**
21
69
  * Skips commented or empty lines when scanning env.template
22
70
  * @param {string} line - Single line
@@ -127,7 +175,7 @@ function loadExistingSecrets(resolvedPath) {
127
175
 
128
176
  try {
129
177
  const content = fs.readFileSync(resolvedPath, 'utf8');
130
- const secrets = yaml.load(content) || {};
178
+ const secrets = loadYamlTolerantOfDuplicateKeys(content);
131
179
  return typeof secrets === 'object' ? secrets : {};
132
180
  } catch (error) {
133
181
  logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
@@ -136,7 +184,7 @@ function loadExistingSecrets(resolvedPath) {
136
184
  }
137
185
 
138
186
  /**
139
- * Saves secrets file
187
+ * Saves secrets file (full overwrite). Use appendSecretsToFile to add keys without changing existing content.
140
188
  * @function saveSecretsFile
141
189
  * @param {string} resolvedPath - Path to secrets file
142
190
  * @param {Object} secrets - Secrets object to save
@@ -158,6 +206,45 @@ function saveSecretsFile(resolvedPath, secrets) {
158
206
  fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
159
207
  }
160
208
 
209
+ const YAML_DUMP_OPTS = { indent: 2, lineWidth: 120, noRefs: true, sortKeys: false };
210
+
211
+ /**
212
+ * Appends secret keys to the end of the secrets file without modifying existing content (preserves comments and structure).
213
+ * Creates the file if it does not exist. For existing files, new keys are appended.
214
+ * When the file has duplicate keys, use loadExistingSecrets (tolerant parse) to read; last occurrence wins.
215
+ *
216
+ * @function appendSecretsToFile
217
+ * @param {string} resolvedPath - Path to secrets file
218
+ * @param {Object} secrets - Key-value object to append (only these keys are written)
219
+ * @throws {Error} If write fails
220
+ */
221
+ function appendSecretsToFile(resolvedPath, secrets) {
222
+ if (!secrets || typeof secrets !== 'object' || Object.keys(secrets).length === 0) {
223
+ return;
224
+ }
225
+ const dir = path.dirname(resolvedPath);
226
+ if (!fs.existsSync(dir)) {
227
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
228
+ }
229
+
230
+ const appendContent = yaml.dump(secrets, YAML_DUMP_OPTS);
231
+
232
+ if (!fs.existsSync(resolvedPath)) {
233
+ fs.writeFileSync(resolvedPath, appendContent, { mode: 0o600 });
234
+ return;
235
+ }
236
+
237
+ let existing = '';
238
+ try {
239
+ const raw = fs.readFileSync(resolvedPath, 'utf8');
240
+ existing = typeof raw === 'string' ? raw : '';
241
+ } catch (err) {
242
+ logger.warn(`Could not read existing secrets file: ${err.message}; appending new keys only.`);
243
+ }
244
+ const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
245
+ fs.writeFileSync(resolvedPath, existing + separator + appendContent, { mode: 0o600 });
246
+ }
247
+
161
248
  /**
162
249
  * Generates missing secret keys in secrets file
163
250
  * Scans env.template for kv:// references and adds missing keys with secure defaults
@@ -188,8 +275,7 @@ async function generateMissingSecrets(envTemplate, secretsPath) {
188
275
  newSecrets[key] = generateSecretValue(key);
189
276
  }
190
277
 
191
- const updatedSecrets = { ...existingSecrets, ...newSecrets };
192
- saveSecretsFile(resolvedPath, updatedSecrets);
278
+ appendSecretsToFile(resolvedPath, newSecrets);
193
279
 
194
280
  logger.log(`✓ Generated ${missingKeys.length} missing secret key(s): ${missingKeys.join(', ')}`);
195
281
  return missingKeys;
@@ -227,11 +313,11 @@ postgres-passwordKeyVault: "admin123"
227
313
 
228
314
  # Redis Secrets
229
315
  redis-passwordKeyVault: ""
230
- redis-urlKeyVault: "redis://\${REDIS_HOST}:\${REDIS_PORT}"
316
+ redis-url: "redis://\${REDIS_HOST}:\${REDIS_PORT}"
231
317
 
232
318
  # Keycloak Secrets
233
319
  keycloak-admin-passwordKeyVault: "admin123"
234
- keycloak-auth-server-urlKeyVault: "http://\${KEYCLOAK_HOST}:\${KEYCLOAK_PORT}"
320
+ keycloak-server-url: "http://\${KEYCLOAK_HOST}:\${KEYCLOAK_PORT}"
235
321
  `;
236
322
 
237
323
  fs.writeFileSync(resolvedPath, defaultSecrets, { mode: 0o600 });
@@ -240,8 +326,10 @@ keycloak-auth-server-urlKeyVault: "http://\${KEYCLOAK_HOST}:\${KEYCLOAK_PORT}"
240
326
  module.exports = {
241
327
  findMissingSecretKeys,
242
328
  generateSecretValue,
329
+ loadYamlTolerantOfDuplicateKeys,
243
330
  loadExistingSecrets,
244
331
  saveSecretsFile,
332
+ appendSecretsToFile,
245
333
  generateMissingSecrets,
246
334
  createDefaultSecrets
247
335
  };
@@ -166,7 +166,7 @@ function getPortFromLocalEnv(localEnv) {
166
166
  }
167
167
 
168
168
  /**
169
- * Gets port from application config file (build.localPort if positive, else port). Uses port-resolver.
169
+ * Gets port from application config file (port only). Uses port-resolver.
170
170
  * @function getPortFromVariablesFile
171
171
  * @param {string} variablesPath - Path to application config
172
172
  * @returns {number|null} Port value or null
@@ -199,7 +199,7 @@ function applyDeveloperIdAdjustment(baseAppPort, devIdNum) {
199
199
 
200
200
  /**
201
201
  * Calculate application port following override chain and developer-id adjustment
202
- * Override chain: env-config.yaml → config.yaml → application.yaml build.localPort → application.yaml port
202
+ * Override chain: env-config.yaml → config.yaml → application.yaml port
203
203
  * @async
204
204
  * @function calculateAppPort
205
205
  * @param {string} [variablesPath] - Path to application config
@@ -212,7 +212,7 @@ async function calculateAppPort(variablesPath, localEnv, envContent, devIdNum) {
212
212
  // Start with env-config value
213
213
  let baseAppPort = getPortFromLocalEnv(localEnv);
214
214
 
215
- // Override with application config build.localPort (strongest)
215
+ // Override with application config port (strongest)
216
216
  const variablesPort = getPortFromVariablesFile(variablesPath);
217
217
  if (variablesPort !== null) {
218
218
  baseAppPort = variablesPort;
@@ -247,13 +247,32 @@ function updateLocalhostUrls(content, baseAppPort, appPort) {
247
247
  }
248
248
 
249
249
  /**
250
- * Adjust infra-related ports in resolved .env content for local environment
251
- * Only handles PORT variable (other ports handled by interpolation)
252
- * Follows flow: getEnvHosts() config.yaml override application config override developer-id adjustment
250
+ * Get the env var name used for PORT in env.template (e.g. PORT=${MISO_PORT} -> MISO_PORT).
251
+ * Used when generating .env for envOutputPath (local, not reload) so we set that var to localPort.
252
+ * @param {string} [variablesPath] - Path to application config (env.template lives in same dir)
253
+ * @returns {string|null} Variable name or null
254
+ */
255
+ function getPortVarFromEnvTemplatePath(variablesPath) {
256
+ if (!variablesPath || !fs.existsSync(variablesPath)) return null;
257
+ const templatePath = path.join(path.dirname(variablesPath), 'env.template');
258
+ if (!fs.existsSync(templatePath)) return null;
259
+ try {
260
+ const content = fs.readFileSync(templatePath, 'utf8');
261
+ const m = content.match(/^PORT\s*=\s*\$\{([A-Za-z0-9_]+)\}/m);
262
+ return m ? m[1] : null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Adjust infra-related ports in resolved .env content for local environment.
270
+ * Own case: when we generate .env for envOutputPath (not reload), we use localPort (application.yaml build.localPort or port).
271
+ * Sets PORT and the template port var (e.g. MISO_PORT) to localPort so the generated .env is correct for local use.
253
272
  * @async
254
273
  * @function adjustLocalEnvPortsInContent
255
274
  * @param {string} envContent - Resolved .env content
256
- * @param {string} [variablesPath] - Path to application config (to read build.localPort)
275
+ * @param {string} [variablesPath] - Path to application config (to read port and template port var)
257
276
  * @returns {Promise<string>} Updated content with local ports
258
277
  */
259
278
  /**
@@ -348,6 +367,11 @@ async function adjustLocalEnvPortsInContent(envContent, variablesPath) {
348
367
  updated = await rewriteInfraEndpoints(updated, 'local');
349
368
 
350
369
  const envVars = await buildEnvVarsForInterpolation(devIdNum);
370
+ envVars.PORT = String(appPort);
371
+ const portVar = getPortVarFromEnvTemplatePath(variablesPath);
372
+ if (portVar) {
373
+ envVars[portVar] = String(appPort);
374
+ }
351
375
  updated = interpolateEnvVars(updated, envVars);
352
376
 
353
377
  return updated;
@@ -447,24 +471,8 @@ function ensureNonEmptySecrets(secrets) {
447
471
  * @returns {Object} Validation result
448
472
  */
449
473
  function validateSecrets(envTemplate, secrets) {
450
- const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
451
- const missing = [];
452
- const lines = envTemplate.split('\n');
453
- for (const line of lines) {
454
- if (isCommentOrEmptyLine(line)) continue;
455
- let match;
456
- kvPattern.lastIndex = 0;
457
- while ((match = kvPattern.exec(line)) !== null) {
458
- const secretKey = match[1];
459
- if (!(secretKey in secrets)) {
460
- missing.push(`kv://${secretKey}`);
461
- }
462
- }
463
- }
464
- return {
465
- valid: missing.length === 0,
466
- missing
467
- };
474
+ const missing = collectMissingSecrets(envTemplate, secrets);
475
+ return { valid: missing.length === 0, missing };
468
476
  }
469
477
 
470
478
  module.exports = {
@@ -54,8 +54,8 @@ async function getActualSecretsPath(secretsPath, _appName) {
54
54
  };
55
55
  }
56
56
 
57
- // Cascading lookup: user's file first (under configured home)
58
- const userSecretsPath = path.join(paths.getAifabrixHome(), 'secrets.local.yaml');
57
+ // Cascading lookup: user's file first (primary home: AIFABRIX_HOME or ~/.aifabrix)
58
+ const userSecretsPath = path.join(paths.getConfigDirForPaths(), 'secrets.local.yaml');
59
59
 
60
60
  // Check config.yaml for canonical secrets path
61
61
  let buildSecretsPath = null;