@aifabrix/builder 2.40.0 → 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 (108) hide show
  1. package/README.md +7 -5
  2. package/integration/hubspot/test.js +1 -1
  3. package/jest.config.manual.js +29 -0
  4. package/lib/api/credential.api.js +40 -0
  5. package/lib/api/dev.api.js +423 -0
  6. package/lib/api/types/credential.types.js +23 -0
  7. package/lib/api/types/dev.types.js +140 -0
  8. package/lib/app/config.js +21 -0
  9. package/lib/app/down.js +2 -1
  10. package/lib/app/index.js +9 -0
  11. package/lib/app/push.js +36 -12
  12. package/lib/app/readme.js +1 -3
  13. package/lib/app/run-env-compose.js +201 -0
  14. package/lib/app/run-helpers.js +121 -118
  15. package/lib/app/run.js +148 -28
  16. package/lib/app/show.js +5 -2
  17. package/lib/build/index.js +11 -3
  18. package/lib/cli/setup-app.js +140 -14
  19. package/lib/cli/setup-auth.js +1 -0
  20. package/lib/cli/setup-dev.js +180 -17
  21. package/lib/cli/setup-environment.js +4 -2
  22. package/lib/cli/setup-external-system.js +71 -21
  23. package/lib/cli/setup-infra.js +29 -2
  24. package/lib/cli/setup-secrets.js +52 -5
  25. package/lib/cli/setup-utility.js +19 -4
  26. package/lib/commands/app-install.js +172 -0
  27. package/lib/commands/app-shell.js +75 -0
  28. package/lib/commands/app-test.js +282 -0
  29. package/lib/commands/app.js +1 -1
  30. package/lib/commands/auth-status.js +36 -3
  31. package/lib/commands/dev-cli-handlers.js +141 -0
  32. package/lib/commands/dev-down.js +114 -0
  33. package/lib/commands/dev-init.js +309 -0
  34. package/lib/commands/secrets-list.js +118 -0
  35. package/lib/commands/secrets-remove.js +97 -0
  36. package/lib/commands/secrets-set.js +30 -17
  37. package/lib/commands/secrets-validate.js +50 -0
  38. package/lib/commands/up-dataplane.js +2 -2
  39. package/lib/commands/up-miso.js +0 -25
  40. package/lib/commands/upload.js +26 -1
  41. package/lib/core/admin-secrets.js +96 -0
  42. package/lib/core/secrets-ensure.js +378 -0
  43. package/lib/core/secrets-env-write.js +157 -0
  44. package/lib/core/secrets.js +147 -81
  45. package/lib/datasource/field-reference-validator.js +91 -0
  46. package/lib/datasource/validate.js +21 -3
  47. package/lib/deployment/environment-config.js +137 -0
  48. package/lib/deployment/environment.js +21 -98
  49. package/lib/deployment/push.js +32 -2
  50. package/lib/external-system/download.js +7 -0
  51. package/lib/external-system/test-auth.js +7 -3
  52. package/lib/external-system/test.js +5 -1
  53. package/lib/generator/index.js +174 -25
  54. package/lib/generator/wizard.js +13 -1
  55. package/lib/infrastructure/helpers.js +103 -20
  56. package/lib/infrastructure/index.js +88 -10
  57. package/lib/infrastructure/services.js +70 -15
  58. package/lib/schema/application-schema.json +24 -3
  59. package/lib/schema/external-system.schema.json +435 -413
  60. package/lib/utils/api.js +3 -3
  61. package/lib/utils/app-register-auth.js +25 -3
  62. package/lib/utils/cli-utils.js +20 -0
  63. package/lib/utils/compose-generator.js +76 -75
  64. package/lib/utils/compose-handlebars-helpers.js +43 -0
  65. package/lib/utils/compose-vector-helper.js +18 -0
  66. package/lib/utils/config-paths.js +127 -2
  67. package/lib/utils/credential-secrets-env.js +267 -0
  68. package/lib/utils/dev-cert-helper.js +122 -0
  69. package/lib/utils/device-code-helpers.js +224 -0
  70. package/lib/utils/device-code.js +37 -336
  71. package/lib/utils/docker-build.js +40 -8
  72. package/lib/utils/env-copy.js +83 -13
  73. package/lib/utils/env-map.js +35 -5
  74. package/lib/utils/env-template.js +6 -5
  75. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  76. package/lib/utils/help-builder.js +15 -2
  77. package/lib/utils/infra-status.js +30 -1
  78. package/lib/utils/local-secrets.js +7 -52
  79. package/lib/utils/mutagen-install.js +195 -0
  80. package/lib/utils/mutagen.js +146 -0
  81. package/lib/utils/paths.js +49 -33
  82. package/lib/utils/port-resolver.js +28 -16
  83. package/lib/utils/remote-dev-auth.js +38 -0
  84. package/lib/utils/remote-docker-env.js +43 -0
  85. package/lib/utils/remote-secrets-loader.js +60 -0
  86. package/lib/utils/secrets-generator.js +94 -6
  87. package/lib/utils/secrets-helpers.js +33 -25
  88. package/lib/utils/secrets-path.js +2 -2
  89. package/lib/utils/secrets-utils.js +52 -1
  90. package/lib/utils/secrets-validation.js +84 -0
  91. package/lib/utils/ssh-key-helper.js +116 -0
  92. package/lib/utils/token-manager-messages.js +90 -0
  93. package/lib/utils/token-manager.js +5 -4
  94. package/lib/utils/variable-transformer.js +3 -3
  95. package/lib/validation/validate.js +1 -1
  96. package/lib/validation/validator.js +65 -0
  97. package/package.json +4 -2
  98. package/scripts/install-local.js +34 -15
  99. package/templates/README.md +0 -1
  100. package/templates/applications/README.md.hbs +4 -4
  101. package/templates/applications/dataplane/application.yaml +5 -4
  102. package/templates/applications/dataplane/env.template +12 -7
  103. package/templates/applications/keycloak/env.template +2 -0
  104. package/templates/applications/miso-controller/application.yaml +1 -0
  105. package/templates/applications/miso-controller/env.template +11 -9
  106. package/templates/external-system/external-system.json.hbs +1 -16
  107. package/templates/python/docker-compose.hbs +49 -23
  108. 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() {
@@ -178,6 +172,12 @@ function checkGlobalProjectRoot() {
178
172
  return null;
179
173
  }
180
174
 
175
+ // In test environment, allow temp dir as project root (Jest sets NODE_ENV=test / JEST_WORKER_ID)
176
+ const isTestEnv = process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined';
177
+ if (isTestEnv) {
178
+ return globalRoot;
179
+ }
180
+
181
181
  // Verify that __dirname is actually within globalRoot
182
182
  const dirnameNormalized = path.resolve(__dirname);
183
183
  const globalRootNormalized = path.resolve(globalRoot);
@@ -189,39 +189,29 @@ function checkGlobalProjectRoot() {
189
189
 
190
190
  /**
191
191
  * Tries different strategies to find project root
192
- * @function tryFindProjectRoot
193
192
  * @returns {string} Found project root
194
193
  */
195
194
  function tryFindProjectRoot() {
196
- // Strategy 1: Check global.PROJECT_ROOT
197
195
  const globalRoot = checkGlobalProjectRoot();
198
196
  if (globalRoot) {
199
197
  cachedProjectRoot = globalRoot;
200
198
  return cachedProjectRoot;
201
199
  }
202
-
203
- // Strategy 2: Walk up from __dirname
204
200
  const foundRoot = findProjectRootByWalkingUp(__dirname);
205
201
  if (foundRoot && hasPackageJson(foundRoot)) {
206
202
  cachedProjectRoot = foundRoot;
207
203
  return cachedProjectRoot;
208
204
  }
209
-
210
- // Strategy 3: Try process.cwd()
211
205
  const cwdRoot = findProjectRootFromCwd();
212
206
  if (cwdRoot && hasPackageJson(cwdRoot)) {
213
207
  cachedProjectRoot = cwdRoot;
214
208
  return cachedProjectRoot;
215
209
  }
216
-
217
- // Strategy 4: Fallback
218
210
  const fallbackRoot = getFallbackProjectRoot();
219
211
  if (hasPackageJson(fallbackRoot)) {
220
212
  cachedProjectRoot = fallbackRoot;
221
213
  return cachedProjectRoot;
222
214
  }
223
-
224
- // Last resort
225
215
  cachedProjectRoot = fallbackRoot;
226
216
  return cachedProjectRoot;
227
217
  }
@@ -236,10 +226,7 @@ function getProjectRoot() {
236
226
  }
237
227
 
238
228
  /**
239
- * Returns the applications base directory for a developer.
240
- * Dev 0: <home>/applications
241
- * Dev > 0: <home>/applications-dev-{id}
242
- *
229
+ * Returns the applications base directory. Dev 0: <home>/applications; Dev > 0: <home>/applications-dev-{id}
243
230
  * @param {number|string} developerId - Developer ID
244
231
  * @returns {string} Absolute path to applications base directory
245
232
  */
@@ -253,19 +240,13 @@ function getApplicationsBaseDir(developerId) {
253
240
  }
254
241
 
255
242
  /**
256
- * Returns the developer-specific application directory.
257
- * Dev 0: points to applications/ (root)
258
- * Dev > 0: <home>/applications-dev-{id} (root)
259
- *
243
+ * Returns the developer-specific application directory. Dev 0: applications/; Dev > 0: applications-dev-{id}
260
244
  * @param {string} appName - Application name
261
245
  * @param {number|string} developerId - Developer ID
262
- * @returns {string} Absolute path to developer-specific app directory
246
+ * @returns {string} Developer-specific app directory (root)
263
247
  */
264
248
  function getDevDirectory(appName, developerId) {
265
249
  const baseDir = getApplicationsBaseDir(developerId);
266
- // All files should be generated at the root of the applications folder
267
- // Dev 0: <home>/applications
268
- // Dev > 0: <home>/applications-dev-{id}
269
250
  return baseDir;
270
251
  }
271
252
 
@@ -286,7 +267,6 @@ function getAppPath(appName, appType) {
286
267
 
287
268
  /**
288
269
  * Base directory for integration/builder: project root when cwd is inside project, else cwd.
289
- * So deploy works when run from integration/<app> (e.g. node deploy.js), and tests using temp dirs still work.
290
270
  * @returns {string} Directory to resolve integration/ and builder/ from
291
271
  */
292
272
  function getIntegrationBuilderBaseDir() {
@@ -301,7 +281,6 @@ function getIntegrationBuilderBaseDir() {
301
281
 
302
282
  /**
303
283
  * Gets the integration folder path for external systems.
304
- * Uses project root when cwd is inside project so deploy works when run from integration/<app> (e.g. node deploy.js).
305
284
  * @param {string} appName - Application name
306
285
  * @returns {string} Absolute path to integration directory
307
286
  */
@@ -314,9 +293,21 @@ function getIntegrationPath(appName) {
314
293
  }
315
294
 
316
295
  /**
317
- * Gets the builder folder path for regular applications.
318
- * When AIFABRIX_BUILDER_DIR is set (e.g. by up-miso/up-dataplane from config aifabrix-env-config),
319
- * 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.
320
311
  * @param {string} appName - Application name
321
312
  * @returns {string} Absolute path to builder directory
322
313
  */
@@ -456,6 +447,29 @@ async function detectAppType(appName, _options = {}) {
456
447
  if (builderResult) return builderResult;
457
448
  throw new Error(`App '${appName}' not found in integration/${appName} or builder/${appName}`);
458
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
+
459
473
  module.exports = {
460
474
  getAifabrixHome,
461
475
  getConfigDirForPaths,
@@ -465,9 +479,11 @@ module.exports = {
465
479
  getProjectRoot,
466
480
  getIntegrationPath,
467
481
  getBuilderPath,
482
+ resolveBuildContext,
468
483
  getDeployJsonPath,
469
484
  resolveApplicationConfigPath,
470
485
  detectAppType,
486
+ getResolveAppPath,
471
487
  clearProjectRootCache
472
488
  };
473
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 = {