@aifabrix/builder 2.22.2 → 2.31.1

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 (63) hide show
  1. package/jest.config.coverage.js +37 -0
  2. package/lib/api/pipeline.api.js +10 -9
  3. package/lib/app-deploy.js +36 -14
  4. package/lib/app-list.js +191 -71
  5. package/lib/app-prompts.js +77 -26
  6. package/lib/app-readme.js +123 -5
  7. package/lib/app-rotate-secret.js +210 -80
  8. package/lib/app-run-helpers.js +200 -172
  9. package/lib/app-run.js +137 -68
  10. package/lib/audit-logger.js +8 -7
  11. package/lib/build.js +161 -250
  12. package/lib/cli.js +73 -65
  13. package/lib/commands/login.js +45 -31
  14. package/lib/commands/logout.js +181 -0
  15. package/lib/commands/secure.js +59 -24
  16. package/lib/config.js +79 -45
  17. package/lib/datasource-deploy.js +89 -29
  18. package/lib/deployer.js +164 -129
  19. package/lib/diff.js +63 -21
  20. package/lib/environment-deploy.js +36 -19
  21. package/lib/external-system-deploy.js +134 -66
  22. package/lib/external-system-download.js +244 -171
  23. package/lib/external-system-test.js +199 -164
  24. package/lib/generator-external.js +145 -72
  25. package/lib/generator-helpers.js +49 -17
  26. package/lib/generator-split.js +105 -58
  27. package/lib/infra.js +101 -131
  28. package/lib/schema/application-schema.json +895 -896
  29. package/lib/schema/env-config.yaml +11 -4
  30. package/lib/template-validator.js +13 -4
  31. package/lib/utils/api.js +8 -8
  32. package/lib/utils/app-register-auth.js +36 -18
  33. package/lib/utils/app-run-containers.js +140 -0
  34. package/lib/utils/auth-headers.js +6 -6
  35. package/lib/utils/build-copy.js +60 -2
  36. package/lib/utils/build-helpers.js +94 -0
  37. package/lib/utils/cli-utils.js +177 -76
  38. package/lib/utils/compose-generator.js +12 -2
  39. package/lib/utils/config-tokens.js +151 -9
  40. package/lib/utils/deployment-errors.js +137 -69
  41. package/lib/utils/deployment-validation-helpers.js +103 -0
  42. package/lib/utils/docker-build.js +57 -0
  43. package/lib/utils/dockerfile-utils.js +13 -3
  44. package/lib/utils/env-copy.js +163 -94
  45. package/lib/utils/env-map.js +226 -86
  46. package/lib/utils/error-formatters/network-errors.js +0 -1
  47. package/lib/utils/external-system-display.js +14 -19
  48. package/lib/utils/external-system-env-helpers.js +107 -0
  49. package/lib/utils/external-system-test-helpers.js +144 -0
  50. package/lib/utils/health-check.js +10 -8
  51. package/lib/utils/infra-status.js +123 -0
  52. package/lib/utils/paths.js +228 -49
  53. package/lib/utils/schema-loader.js +125 -57
  54. package/lib/utils/token-manager.js +3 -3
  55. package/lib/utils/yaml-preserve.js +55 -16
  56. package/lib/validate.js +87 -89
  57. package/package.json +7 -5
  58. package/scripts/ci-fix.sh +19 -0
  59. package/scripts/ci-simulate.sh +19 -0
  60. package/scripts/install-local.js +210 -0
  61. package/templates/applications/miso-controller/test.yaml +1 -0
  62. package/templates/python/Dockerfile.hbs +8 -45
  63. package/templates/typescript/Dockerfile.hbs +8 -42
@@ -19,6 +19,163 @@ const { rewriteInfraEndpoints } = require('./env-endpoints');
19
19
  const { buildEnvVarMap } = require('./env-map');
20
20
  const { interpolateEnvVars } = require('./secrets-helpers');
21
21
 
22
+ /**
23
+ * Read developer ID from config file synchronously
24
+ * @param {Object} config - Config object
25
+ * @returns {number|null} Developer ID or null if not found
26
+ */
27
+ function readDeveloperIdFromConfig(config) {
28
+ const configPath = config && config.CONFIG_FILE ? config.CONFIG_FILE : null;
29
+ if (!configPath || !fs.existsSync(configPath)) {
30
+ return null;
31
+ }
32
+
33
+ try {
34
+ const cfgContent = fs.readFileSync(configPath, 'utf8');
35
+ const cfg = yaml.load(cfgContent) || {};
36
+ const raw = cfg['developer-id'];
37
+ if (typeof raw === 'number') {
38
+ return raw;
39
+ }
40
+ if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) {
41
+ return parseInt(raw, 10);
42
+ }
43
+ } catch {
44
+ // ignore, will fallback to 0
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Resolve output path for env file
52
+ * @param {string} rawOutputPath - Raw output path from variables.yaml
53
+ * @param {string} variablesPath - Path to variables.yaml
54
+ * @returns {string} Resolved output path
55
+ */
56
+ function resolveEnvOutputPath(rawOutputPath, variablesPath) {
57
+ let outputPath;
58
+ if (path.isAbsolute(rawOutputPath)) {
59
+ outputPath = rawOutputPath;
60
+ } else {
61
+ const variablesDir = path.dirname(variablesPath);
62
+ outputPath = path.resolve(variablesDir, rawOutputPath);
63
+ }
64
+ if (!outputPath.endsWith('.env')) {
65
+ if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) {
66
+ outputPath = path.join(outputPath, '.env');
67
+ } else {
68
+ outputPath = path.join(outputPath, '.env');
69
+ }
70
+ }
71
+ return outputPath;
72
+ }
73
+
74
+ /**
75
+ * Calculate developer-specific app port
76
+ * @param {number} baseAppPort - Base application port
77
+ * @returns {number} Developer-specific app port
78
+ */
79
+ function calculateDevAppPort(baseAppPort) {
80
+ const devIdRaw = process.env.AIFABRIX_DEVELOPERID;
81
+ let devIdNum = Number.isFinite(parseInt(devIdRaw, 10)) ? parseInt(devIdRaw, 10) : null;
82
+ try {
83
+ if (devIdNum === null) {
84
+ devIdNum = readDeveloperIdFromConfig(config) || 0;
85
+ }
86
+ } catch {
87
+ devIdNum = 0;
88
+ }
89
+ return devIdNum === 0 ? baseAppPort : (baseAppPort + (devIdNum * 100));
90
+ }
91
+
92
+ /**
93
+ * Update PORT in env content
94
+ * @param {string} envContent - Environment file content
95
+ * @param {number} appPort - Application port
96
+ * @returns {string} Updated env content
97
+ */
98
+ function updatePortInEnv(envContent, appPort) {
99
+ if (/^PORT\s*=.*$/m.test(envContent)) {
100
+ return envContent.replace(/^PORT\s*=\s*.*$/m, `PORT=${appPort}`);
101
+ }
102
+ return `${envContent}\nPORT=${appPort}\n`;
103
+ }
104
+
105
+ /**
106
+ * Update localhost URLs in env content
107
+ * @param {string} envContent - Environment file content
108
+ * @param {number} baseAppPort - Base application port
109
+ * @param {number} appPort - Developer-specific application port
110
+ * @returns {string} Updated env content
111
+ */
112
+ function updateLocalhostUrls(envContent, baseAppPort, appPort) {
113
+ const localhostUrlPattern = /(https?:\/\/localhost:)(\d+)(\b[^ \n]*)?/g;
114
+ return envContent.replace(localhostUrlPattern, (match, prefix, portNum, rest = '') => {
115
+ const num = parseInt(portNum, 10);
116
+ if (num === baseAppPort) {
117
+ return `${prefix}${appPort}${rest || ''}`;
118
+ }
119
+ return match;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Extract env vars from content for interpolation
125
+ * @param {string} envContent - Environment file content
126
+ * @param {Object} envVars - Existing env vars map
127
+ * @returns {Object} Updated env vars map
128
+ */
129
+ function extractEnvVarsFromContent(envContent, envVars) {
130
+ const redisHostMatch = envContent.match(/^REDIS_HOST\s*=\s*([^\r\n$]+)/m);
131
+ const redisPortMatch = envContent.match(/^REDIS_PORT\s*=\s*([^\r\n$]+)/m);
132
+ const dbHostMatch = envContent.match(/^DB_HOST\s*=\s*([^\r\n$]+)/m);
133
+ const dbPortMatch = envContent.match(/^DB_PORT\s*=\s*([^\r\n$]+)/m);
134
+ if (redisHostMatch && redisHostMatch[1] && !redisHostMatch[1].includes('${')) {
135
+ envVars.REDIS_HOST = redisHostMatch[1].trim();
136
+ }
137
+ if (redisPortMatch && redisPortMatch[1] && !redisPortMatch[1].includes('${')) {
138
+ envVars.REDIS_PORT = redisPortMatch[1].trim();
139
+ }
140
+ if (dbHostMatch && dbHostMatch[1] && !dbHostMatch[1].includes('${')) {
141
+ envVars.DB_HOST = dbHostMatch[1].trim();
142
+ }
143
+ if (dbPortMatch && dbPortMatch[1] && !dbPortMatch[1].includes('${')) {
144
+ envVars.DB_PORT = dbPortMatch[1].trim();
145
+ }
146
+ return envVars;
147
+ }
148
+
149
+ /**
150
+ * Patch env content for local development
151
+ * @async
152
+ * @param {string} envContent - Original env content
153
+ * @param {Object} variables - Variables from variables.yaml
154
+ * @returns {Promise<string>} Patched env content
155
+ */
156
+ async function patchEnvContentForLocal(envContent, variables) {
157
+ const baseAppPort = variables.build?.localPort || variables.port || 3000;
158
+ const appPort = calculateDevAppPort(baseAppPort);
159
+ const devIdNum = readDeveloperIdFromConfig(config) || 0;
160
+ const infraPorts = devConfig.getDevPorts(devIdNum);
161
+
162
+ // Update PORT
163
+ envContent = updatePortInEnv(envContent, appPort);
164
+
165
+ // Update localhost URLs
166
+ envContent = updateLocalhostUrls(envContent, baseAppPort, appPort);
167
+
168
+ // Rewrite infra endpoints
169
+ envContent = await rewriteInfraEndpoints(envContent, 'local', infraPorts);
170
+
171
+ // Interpolate ${VAR} references
172
+ const envVars = await buildEnvVarMap('local', null, devIdNum);
173
+ const updatedEnvVars = extractEnvVarsFromContent(envContent, envVars);
174
+ envContent = interpolateEnvVars(envContent, updatedEnvVars);
175
+
176
+ return envContent;
177
+ }
178
+
22
179
  /**
23
180
  * Process and optionally copy env file to envOutputPath if configured
24
181
  * Regenerates .env file with env=local for local development (apps/.env)
@@ -38,113 +195,25 @@ async function processEnvVariables(envPath, variablesPath, appName, secretsPath)
38
195
  if (!variables?.build?.envOutputPath || variables.build.envOutputPath === null) {
39
196
  return;
40
197
  }
41
- // Resolve output path: absolute stays as-is; relative is resolved against variables.yaml directory
42
- const rawOutputPath = variables.build.envOutputPath;
43
- let outputPath;
44
- if (path.isAbsolute(rawOutputPath)) {
45
- outputPath = rawOutputPath;
46
- } else {
47
- const variablesDir = path.dirname(variablesPath);
48
- outputPath = path.resolve(variablesDir, rawOutputPath);
49
- }
50
- if (!outputPath.endsWith('.env')) {
51
- if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) {
52
- outputPath = path.join(outputPath, '.env');
53
- } else {
54
- outputPath = path.join(outputPath, '.env');
55
- }
56
- }
198
+
199
+ // Resolve output path
200
+ const outputPath = resolveEnvOutputPath(variables.build.envOutputPath, variablesPath);
57
201
  const outputDir = path.dirname(outputPath);
58
202
  if (!fs.existsSync(outputDir)) {
59
203
  fs.mkdirSync(outputDir, { recursive: true });
60
204
  }
61
205
 
62
206
  // Regenerate .env file with env=local instead of copying docker-generated file
63
- // This ensures all variables use localhost instead of docker service names
64
207
  if (appName) {
65
208
  const { generateEnvContent } = require('../secrets');
66
- // Generate local .env content (without writing to builder/.env to avoid overwriting docker version)
67
209
  const localEnvContent = await generateEnvContent(appName, secretsPath, 'local', false);
68
- // Write to output path
69
210
  fs.writeFileSync(outputPath, localEnvContent, { mode: 0o600 });
70
211
  logger.log(chalk.green(`✓ Generated local .env at: ${variables.build.envOutputPath}`));
71
212
  } else {
72
213
  // Fallback: if appName not provided, use old patching approach
73
- let envContent = fs.readFileSync(envPath, 'utf8');
74
- // Determine base app port and compute developer-specific app port
75
- const baseAppPort = variables.build?.localPort || variables.port || 3000;
76
- const devIdRaw = process.env.AIFABRIX_DEVELOPERID;
77
- // Best effort: parse from env first, otherwise rely on config (may throw if async, so guarded below)
78
- let devIdNum = Number.isFinite(parseInt(devIdRaw, 10)) ? parseInt(devIdRaw, 10) : null;
79
- try {
80
- if (devIdNum === null) {
81
- // Try to read developer-id from config file synchronously if present
82
- const configPath = config && config.CONFIG_FILE ? config.CONFIG_FILE : null;
83
- if (configPath && fs.existsSync(configPath)) {
84
- try {
85
- const cfgContent = fs.readFileSync(configPath, 'utf8');
86
- const cfg = yaml.load(cfgContent) || {};
87
- const raw = cfg['developer-id'];
88
- if (typeof raw === 'number') {
89
- devIdNum = raw;
90
- } else if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) {
91
- devIdNum = parseInt(raw, 10);
92
- }
93
- } catch {
94
- // ignore, will fallback to 0
95
- }
96
- }
97
- if (devIdNum === null || Number.isNaN(devIdNum)) {
98
- devIdNum = 0;
99
- }
100
- }
101
- } catch {
102
- devIdNum = 0;
103
- }
104
- const appPort = devIdNum === 0 ? baseAppPort : (baseAppPort + (devIdNum * 100));
105
- const infraPorts = devConfig.getDevPorts(devIdNum);
106
-
107
- // Update PORT (replace or append)
108
- if (/^PORT\s*=.*$/m.test(envContent)) {
109
- envContent = envContent.replace(/^PORT\s*=\s*.*$/m, `PORT=${appPort}`);
110
- } else {
111
- envContent = `${envContent}\nPORT=${appPort}\n`;
112
- }
113
-
114
- // Update localhost URLs that point to the base app port to the dev-specific app port
115
- const localhostUrlPattern = /(https?:\/\/localhost:)(\d+)(\b[^ \n]*)?/g;
116
- envContent = envContent.replace(localhostUrlPattern, (match, prefix, portNum, rest = '') => {
117
- const num = parseInt(portNum, 10);
118
- if (num === baseAppPort) {
119
- return `${prefix}${appPort}${rest || ''}`;
120
- }
121
- return match;
122
- });
123
- // Rewrite infra endpoints using env-config mapping for local context
124
- envContent = await rewriteInfraEndpoints(envContent, 'local', infraPorts);
125
- // Interpolate ${VAR} references created by rewriteInfraEndpoints
126
- // Extract actual values from updated content to use for interpolation
127
- const envVars = await buildEnvVarMap('local', null, devIdNum);
128
- // Extract REDIS_HOST, REDIS_PORT, DB_HOST, DB_PORT from updated content if present
129
- // Only extract if the value doesn't contain ${VAR} references (to avoid circular interpolation)
130
- const redisHostMatch = envContent.match(/^REDIS_HOST\s*=\s*([^\r\n$]+)/m);
131
- const redisPortMatch = envContent.match(/^REDIS_PORT\s*=\s*([^\r\n$]+)/m);
132
- const dbHostMatch = envContent.match(/^DB_HOST\s*=\s*([^\r\n$]+)/m);
133
- const dbPortMatch = envContent.match(/^DB_PORT\s*=\s*([^\r\n$]+)/m);
134
- if (redisHostMatch && redisHostMatch[1] && !redisHostMatch[1].includes('${')) {
135
- envVars.REDIS_HOST = redisHostMatch[1].trim();
136
- }
137
- if (redisPortMatch && redisPortMatch[1] && !redisPortMatch[1].includes('${')) {
138
- envVars.REDIS_PORT = redisPortMatch[1].trim();
139
- }
140
- if (dbHostMatch && dbHostMatch[1] && !dbHostMatch[1].includes('${')) {
141
- envVars.DB_HOST = dbHostMatch[1].trim();
142
- }
143
- if (dbPortMatch && dbPortMatch[1] && !dbPortMatch[1].includes('${')) {
144
- envVars.DB_PORT = dbPortMatch[1].trim();
145
- }
146
- envContent = interpolateEnvVars(envContent, envVars);
147
- fs.writeFileSync(outputPath, envContent, { mode: 0o600 });
214
+ const envContent = fs.readFileSync(envPath, 'utf8');
215
+ const patchedContent = await patchEnvContentForLocal(envContent, variables);
216
+ fs.writeFileSync(outputPath, patchedContent, { mode: 0o600 });
148
217
  logger.log(chalk.green(`✓ Copied .env to: ${variables.build.envOutputPath}`));
149
218
  }
150
219
  }
@@ -15,70 +15,147 @@ const { loadEnvConfig } = require('./env-config-loader');
15
15
  const config = require('../config');
16
16
 
17
17
  /**
18
- * Build environment variable map for interpolation based on env-config.yaml
19
- * - Supports values like "host:port" by splitting into *_HOST (host) and *_PORT (port)
20
- * - Merges overrides from ~/.aifabrix/config.yaml under environments.{env}
21
- * - Applies aifabrix-localhost override for local context if configured
22
- * - Applies developer-id adjustment to port variables for local context
18
+ * Load base environment variables from env-config.yaml
23
19
  * @async
24
- * @function buildEnvVarMap
20
+ * @function loadBaseVars
25
21
  * @param {'docker'|'local'} context - Environment context
26
- * @param {Object} [osModule] - Optional os module (for testing). If not provided, requires 'os'
27
- * @param {number|null} [developerId] - Optional developer ID for port adjustment. If not provided, will be fetched from config for local context.
28
- * @returns {Promise<Object>} Map of variables for interpolation
22
+ * @returns {Promise<Object>} Base environment variables
29
23
  */
30
- async function buildEnvVarMap(context, osModule = null, developerId = null) {
31
- // Load env-config (base + user override if configured)
32
- let baseVars = {};
24
+ async function loadBaseVars(context) {
33
25
  try {
34
26
  const envCfg = await loadEnvConfig();
35
27
  const envs = envCfg && envCfg.environments ? envCfg.environments : {};
36
- baseVars = { ...(envs[context] || {}) };
28
+ return { ...(envs[context] || {}) };
37
29
  } catch {
38
- baseVars = {};
30
+ return {};
39
31
  }
32
+ }
40
33
 
41
- // Get os module - use provided one or require it
42
- const os = osModule || require('os');
43
-
44
- // Merge overrides from ~/.aifabrix/config.yaml
45
- let overrideVars = {};
34
+ /**
35
+ * Load override variables from ~/.aifabrix/config.yaml
36
+ * @function loadOverrideVars
37
+ * @param {'docker'|'local'} context - Environment context
38
+ * @param {Object} os - OS module instance
39
+ * @returns {Object} Override environment variables
40
+ */
41
+ function loadOverrideVars(context, os) {
46
42
  try {
47
43
  const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
48
44
  if (fs.existsSync(cfgPath)) {
49
45
  const cfgContent = fs.readFileSync(cfgPath, 'utf8');
50
46
  const cfg = yaml.load(cfgContent) || {};
51
47
  if (cfg && cfg.environments && cfg.environments[context]) {
52
- overrideVars = { ...cfg.environments[context] };
48
+ return { ...cfg.environments[context] };
53
49
  }
54
50
  }
55
51
  } catch {
56
52
  // ignore overrides on error
57
53
  }
54
+ return {};
55
+ }
58
56
 
59
- // Apply aifabrix-localhost override for local hostnames
60
- let localhostOverride = null;
61
- if (context === 'local') {
62
- try {
63
- const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
64
- if (fs.existsSync(cfgPath)) {
65
- const cfgContent = fs.readFileSync(cfgPath, 'utf8');
66
- const cfg = yaml.load(cfgContent) || {};
67
- if (typeof cfg['aifabrix-localhost'] === 'string' && cfg['aifabrix-localhost'].trim().length > 0) {
68
- localhostOverride = cfg['aifabrix-localhost'].trim();
69
- }
57
+ /**
58
+ * Get localhost override value from config
59
+ * @function getLocalhostOverride
60
+ * @param {Object} os - OS module instance
61
+ * @returns {string|null} Localhost override value or null
62
+ */
63
+ function getLocalhostOverride(os) {
64
+ try {
65
+ const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
66
+ if (fs.existsSync(cfgPath)) {
67
+ const cfgContent = fs.readFileSync(cfgPath, 'utf8');
68
+ const cfg = yaml.load(cfgContent) || {};
69
+ if (typeof cfg['aifabrix-localhost'] === 'string' && cfg['aifabrix-localhost'].trim().length > 0) {
70
+ return cfg['aifabrix-localhost'].trim();
70
71
  }
71
- } catch {
72
- // ignore
73
72
  }
73
+ } catch {
74
+ // ignore
74
75
  }
76
+ return null;
77
+ }
75
78
 
76
- const merged = { ...baseVars, ...overrideVars };
79
+ /**
80
+ * Get host value with localhost override applied if needed
81
+ * @function getHostValue
82
+ * @param {string} host - Original host value
83
+ * @param {'docker'|'local'} context - Environment context
84
+ * @param {string|null} localhostOverride - Localhost override value
85
+ * @returns {string} Host value with override applied
86
+ */
87
+ function getHostValue(host, context, localhostOverride) {
88
+ if (context === 'local' && host === 'localhost' && localhostOverride) {
89
+ return localhostOverride;
90
+ }
91
+ return host;
92
+ }
93
+
94
+ /**
95
+ * Handle host:port value for key ending with _HOST
96
+ * @function handleHostPortWithHostSuffix
97
+ * @param {Object} result - Result object to update
98
+ * @param {string} key - Environment variable key
99
+ * @param {string} host - Host value
100
+ * @param {string} port - Port value
101
+ * @param {Object} options - Normalization options
102
+ * @param {'docker'|'local'} options.context - Environment context
103
+ * @param {string|null} options.localhostOverride - Localhost override value
104
+ */
105
+ function handleHostPortWithHostSuffix(result, key, host, port, options) {
106
+ const root = key.replace(/_HOST$/, '');
107
+ const hostValue = getHostValue(host, options.context, options.localhostOverride);
108
+ result[key] = hostValue;
109
+ result[`${root}_PORT`] = port;
110
+ }
77
111
 
78
- // Normalize map: if VAR value is "host:port" and VAR ends with "_HOST",
79
- // expose VAR as host only and also provide "<ROOT>_PORT"
80
- // If VAR value is "host:port" but VAR doesn't end with "_HOST", still split to VAR_HOST/VAR_PORT
112
+ /**
113
+ * Handle host:port value for generic key
114
+ * @function handleHostPortGeneric
115
+ * @param {Object} result - Result object to update
116
+ * @param {string} key - Environment variable key
117
+ * @param {string} host - Host value
118
+ * @param {string} port - Port value
119
+ * @param {Object} options - Normalization options
120
+ * @param {'docker'|'local'} options.context - Environment context
121
+ * @param {string|null} options.localhostOverride - Localhost override value
122
+ */
123
+ function handleHostPortGeneric(result, key, host, port, options) {
124
+ const hostValue = getHostValue(host, options.context, options.localhostOverride);
125
+ result[`${key}_HOST`] = hostValue;
126
+ result[`${key}_PORT`] = port;
127
+ result[key] = hostValue;
128
+ }
129
+
130
+ /**
131
+ * Handle plain value (non-host:port)
132
+ * @function handlePlainValue
133
+ * @param {Object} result - Result object to update
134
+ * @param {string} key - Environment variable key
135
+ * @param {string} rawVal - Raw value
136
+ * @param {Object} options - Normalization options
137
+ * @param {'docker'|'local'} options.context - Environment context
138
+ * @param {string|null} options.localhostOverride - Localhost override value
139
+ */
140
+ function handlePlainValue(result, key, rawVal, options) {
141
+ let val = rawVal;
142
+ if (options.context === 'local' && /_HOST$/.test(key) && rawVal === 'localhost' && options.localhostOverride) {
143
+ val = options.localhostOverride;
144
+ }
145
+ result[key] = val;
146
+ }
147
+
148
+ /**
149
+ * Normalize environment variable map by splitting host:port values
150
+ * @function normalizeEnvVars
151
+ * @param {Object} merged - Merged environment variables
152
+ * @param {'docker'|'local'} context - Environment context
153
+ * @param {string|null} localhostOverride - Localhost override value
154
+ * @returns {Object} Normalized environment variables
155
+ */
156
+ function normalizeEnvVars(merged, context, localhostOverride) {
81
157
  const result = {};
158
+ const options = { context, localhostOverride };
82
159
  for (const [key, rawVal] of Object.entries(merged)) {
83
160
  if (typeof rawVal !== 'string') {
84
161
  result[key] = rawVal;
@@ -89,70 +166,133 @@ async function buildEnvVarMap(context, osModule = null, developerId = null) {
89
166
  const host = hostPortMatch[1];
90
167
  const port = hostPortMatch[2];
91
168
  if (/_HOST$/.test(key)) {
92
- // Example: DB_HOST: "postgres:5432" -> DB_HOST="postgres", DB_PORT="5432"
93
- const root = key.replace(/_HOST$/, '');
94
- const hostValue = context === 'local' && host === 'localhost' && localhostOverride ? localhostOverride : host;
95
- result[key] = hostValue;
96
- result[`${root}_PORT`] = port;
169
+ handleHostPortWithHostSuffix(result, key, host, port, options);
97
170
  } else {
98
- // Generic key with host:port -> expose KEY_HOST and KEY_PORT, and keep KEY as host
99
- const hostValue = context === 'local' && host === 'localhost' && localhostOverride ? localhostOverride : host;
100
- result[`${key}_HOST`] = hostValue;
101
- result[`${key}_PORT`] = port;
102
- result[key] = hostValue;
171
+ handleHostPortGeneric(result, key, host, port, options);
103
172
  }
104
173
  } else {
105
- // Plain value
106
- let val = rawVal;
107
- if (context === 'local' && /_HOST$/.test(key) && rawVal === 'localhost' && localhostOverride) {
108
- val = localhostOverride;
109
- }
110
- result[key] = val;
174
+ handlePlainValue(result, key, rawVal, options);
111
175
  }
112
176
  }
177
+ return result;
178
+ }
113
179
 
114
- // Apply developer-id adjustment to port variables for local context
115
- if (context === 'local') {
116
- let devIdNum = 0;
117
- if (developerId !== null && developerId !== undefined) {
118
- const parsed = typeof developerId === 'number' ? developerId : parseInt(developerId, 10);
180
+ /**
181
+ * Get developer ID number from parameter or config
182
+ * @async
183
+ * @function getDeveloperIdNumber
184
+ * @param {number|null} developerId - Optional developer ID parameter
185
+ * @returns {Promise<number>} Developer ID number (0 if not available)
186
+ */
187
+ async function getDeveloperIdNumber(developerId) {
188
+ if (developerId !== null && developerId !== undefined) {
189
+ const parsed = typeof developerId === 'number' ? developerId : parseInt(developerId, 10);
190
+ if (!Number.isNaN(parsed)) {
191
+ return parsed;
192
+ }
193
+ }
194
+ try {
195
+ const devId = await config.getDeveloperId();
196
+ if (devId !== null && devId !== undefined) {
197
+ const parsed = parseInt(devId, 10);
119
198
  if (!Number.isNaN(parsed)) {
120
- devIdNum = parsed;
199
+ return parsed;
121
200
  }
122
- } else {
123
- // Get developer-id from config if not provided
124
- try {
125
- const devId = await config.getDeveloperId();
126
- if (devId !== null && devId !== undefined) {
127
- const parsed = parseInt(devId, 10);
128
- if (!Number.isNaN(parsed)) {
129
- devIdNum = parsed;
130
- }
131
- }
132
- } catch {
133
- // ignore, will use 0
201
+ }
202
+ } catch {
203
+ // ignore, will use 0
204
+ }
205
+ return 0;
206
+ }
207
+
208
+ /**
209
+ * Apply developer-id adjustment to port variables for local context
210
+ * @function applyLocalPortAdjustment
211
+ * @param {Object} result - Environment variable map
212
+ * @param {number} devIdNum - Developer ID number
213
+ */
214
+ function applyLocalPortAdjustment(result, devIdNum) {
215
+ if (devIdNum === 0) {
216
+ return;
217
+ }
218
+ for (const [key, value] of Object.entries(result)) {
219
+ if (/_PORT$/.test(key)) {
220
+ let portVal;
221
+ if (typeof value === 'string') {
222
+ portVal = parseInt(value, 10);
223
+ } else if (typeof value === 'number') {
224
+ portVal = value;
225
+ } else {
226
+ continue;
227
+ }
228
+ if (!Number.isNaN(portVal)) {
229
+ result[key] = String(portVal + (devIdNum * 100));
134
230
  }
135
231
  }
232
+ }
233
+ }
136
234
 
137
- // Apply adjustment to all *_PORT variables
138
- if (devIdNum !== 0) {
139
- for (const [key, value] of Object.entries(result)) {
140
- if (/_PORT$/.test(key)) {
141
- let portVal;
142
- if (typeof value === 'string') {
143
- portVal = parseInt(value, 10);
144
- } else if (typeof value === 'number') {
145
- portVal = value;
146
- } else {
147
- continue;
148
- }
149
- if (!Number.isNaN(portVal)) {
150
- result[key] = String(portVal + (devIdNum * 100));
151
- }
235
+ /**
236
+ * Calculate public ports for docker context
237
+ * @function calculateDockerPublicPorts
238
+ * @param {Object} result - Environment variable map
239
+ * @param {number} devIdNum - Developer ID number
240
+ */
241
+ function calculateDockerPublicPorts(result, devIdNum) {
242
+ if (devIdNum <= 0) {
243
+ return;
244
+ }
245
+ for (const [key, value] of Object.entries(result)) {
246
+ // Match any variable ending with _PORT (e.g., MISO_PORT, KEYCLOAK_PORT, DB_PORT)
247
+ if (/_PORT$/.test(key) && !/_PUBLIC_PORT$/.test(key)) {
248
+ const publicPortKey = key.replace(/_PORT$/, '_PUBLIC_PORT');
249
+ // Skip if public port already exists (allow manual override)
250
+ if (result[publicPortKey] === undefined) {
251
+ let portVal;
252
+ if (typeof value === 'string') {
253
+ portVal = parseInt(value, 10);
254
+ } else if (typeof value === 'number') {
255
+ portVal = value;
256
+ } else {
257
+ continue;
258
+ }
259
+ if (!Number.isNaN(portVal)) {
260
+ result[publicPortKey] = String(portVal + (devIdNum * 100));
152
261
  }
153
262
  }
154
263
  }
155
264
  }
265
+ }
266
+
267
+ /**
268
+ * Build environment variable map for interpolation based on env-config.yaml
269
+ * - Supports values like "host:port" by splitting into *_HOST (host) and *_PORT (port)
270
+ * - Merges overrides from ~/.aifabrix/config.yaml under environments.{env}
271
+ * - Applies aifabrix-localhost override for local context if configured
272
+ * - Applies developer-id adjustment to port variables for local context
273
+ * - Calculates *_PUBLIC_PORT for docker context (basePort + developer-id * 100)
274
+ * @async
275
+ * @function buildEnvVarMap
276
+ * @param {'docker'|'local'} context - Environment context
277
+ * @param {Object} [osModule] - Optional os module (for testing). If not provided, requires 'os'
278
+ * @param {number|null} [developerId] - Optional developer ID for port adjustment. If not provided, will be fetched from config for local context.
279
+ * @returns {Promise<Object>} Map of variables for interpolation
280
+ */
281
+ async function buildEnvVarMap(context, osModule = null, developerId = null) {
282
+ const baseVars = await loadBaseVars(context);
283
+ const os = osModule || require('os');
284
+ const overrideVars = loadOverrideVars(context, os);
285
+ const localhostOverride = context === 'local' ? getLocalhostOverride(os) : null;
286
+ const merged = { ...baseVars, ...overrideVars };
287
+ const result = normalizeEnvVars(merged, context, localhostOverride);
288
+
289
+ if (context === 'local') {
290
+ const devIdNum = await getDeveloperIdNumber(developerId);
291
+ applyLocalPortAdjustment(result, devIdNum);
292
+ } else if (context === 'docker') {
293
+ const devIdNum = await getDeveloperIdNumber(developerId);
294
+ calculateDockerPublicPorts(result, devIdNum);
295
+ }
156
296
 
157
297
  return result;
158
298
  }
@@ -63,4 +63,3 @@ function formatNetworkError(errorMessage, errorData) {
63
63
  module.exports = {
64
64
  formatNetworkError
65
65
  };
66
-