@aifabrix/builder 2.4.0 → 2.5.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 (38) hide show
  1. package/README.md +3 -0
  2. package/lib/app-down.js +123 -0
  3. package/lib/app.js +4 -2
  4. package/lib/build.js +19 -13
  5. package/lib/cli.js +52 -9
  6. package/lib/commands/secure.js +5 -40
  7. package/lib/config.js +26 -4
  8. package/lib/env-reader.js +3 -2
  9. package/lib/generator.js +0 -9
  10. package/lib/infra.js +30 -3
  11. package/lib/schema/application-schema.json +0 -15
  12. package/lib/schema/env-config.yaml +8 -8
  13. package/lib/secrets.js +167 -253
  14. package/lib/templates.js +10 -18
  15. package/lib/utils/api-error-handler.js +182 -147
  16. package/lib/utils/api.js +144 -354
  17. package/lib/utils/build-copy.js +6 -13
  18. package/lib/utils/compose-generator.js +2 -1
  19. package/lib/utils/device-code.js +349 -0
  20. package/lib/utils/env-config-loader.js +102 -0
  21. package/lib/utils/env-copy.js +131 -0
  22. package/lib/utils/env-endpoints.js +209 -0
  23. package/lib/utils/env-map.js +116 -0
  24. package/lib/utils/env-ports.js +60 -0
  25. package/lib/utils/environment-checker.js +39 -6
  26. package/lib/utils/image-name.js +49 -0
  27. package/lib/utils/paths.js +22 -20
  28. package/lib/utils/secrets-generator.js +3 -3
  29. package/lib/utils/secrets-helpers.js +359 -0
  30. package/lib/utils/secrets-path.js +12 -36
  31. package/lib/utils/secrets-url.js +38 -0
  32. package/lib/utils/secrets-utils.js +1 -42
  33. package/lib/utils/variable-transformer.js +0 -9
  34. package/package.json +1 -1
  35. package/templates/applications/README.md.hbs +4 -2
  36. package/templates/applications/miso-controller/env.template +1 -1
  37. package/templates/infra/compose.yaml +4 -0
  38. package/templates/infra/compose.yaml.hbs +9 -4
@@ -0,0 +1,349 @@
1
+ /**
2
+ * AI Fabrix Builder Device Code Flow Utilities
3
+ *
4
+ * Handles OAuth2 Device Code Flow (RFC 8628) authentication
5
+ * Supports device code initiation, token polling, and token refresh
6
+ *
7
+ * @fileoverview Device code flow utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ // Lazy require to avoid circular dependency
13
+ let makeApiCall;
14
+ function getMakeApiCall() {
15
+ if (!makeApiCall) {
16
+ const api = require('./api');
17
+ makeApiCall = api.makeApiCall;
18
+ }
19
+ return makeApiCall;
20
+ }
21
+
22
+ /**
23
+ * Parses device code response from API
24
+ * Matches OpenAPI DeviceCodeResponse schema (camelCase)
25
+ * @function parseDeviceCodeResponse
26
+ * @param {Object} response - API response object
27
+ * @returns {Object} Parsed device code response
28
+ * @throws {Error} If response is invalid
29
+ */
30
+ function parseDeviceCodeResponse(response) {
31
+ // OpenAPI spec: { success: boolean, data: DeviceCodeResponse, timestamp: string }
32
+ const apiResponse = response.data;
33
+ const responseData = apiResponse.data || apiResponse;
34
+
35
+ // OpenAPI spec uses camelCase: deviceCode, userCode, verificationUri, expiresIn, interval
36
+ const deviceCode = responseData.deviceCode;
37
+ const userCode = responseData.userCode;
38
+ const verificationUri = responseData.verificationUri;
39
+ const expiresIn = responseData.expiresIn || 600;
40
+ const interval = responseData.interval || 5;
41
+
42
+ if (!deviceCode || !userCode || !verificationUri) {
43
+ throw new Error('Invalid device code response: missing required fields');
44
+ }
45
+
46
+ // Return in snake_case for internal consistency (used by existing code)
47
+ return {
48
+ device_code: deviceCode,
49
+ user_code: userCode,
50
+ verification_uri: verificationUri,
51
+ expires_in: expiresIn,
52
+ interval: interval
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Initiates OAuth2 Device Code Flow
58
+ * Calls the device code endpoint to get device_code and user_code
59
+ *
60
+ * @async
61
+ * @function initiateDeviceCodeFlow
62
+ * @param {string} controllerUrl - Base URL of the controller
63
+ * @param {string} environment - Environment key (e.g., 'miso', 'dev', 'tst', 'pro')
64
+ * @returns {Promise<Object>} Device code response with device_code, user_code, verification_uri, expires_in, interval
65
+ * @throws {Error} If initiation fails
66
+ */
67
+ async function initiateDeviceCodeFlow(controllerUrl, environment) {
68
+ if (!environment || typeof environment !== 'string') {
69
+ throw new Error('Environment key is required');
70
+ }
71
+
72
+ const url = `${controllerUrl}/api/v1/auth/login?environment=${encodeURIComponent(environment)}`;
73
+ const response = await getMakeApiCall()(url, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json'
77
+ }
78
+ });
79
+
80
+ if (!response.success) {
81
+ throw new Error(`Device code initiation failed: ${response.error || 'Unknown error'}`);
82
+ }
83
+
84
+ return parseDeviceCodeResponse(response);
85
+ }
86
+
87
+ /**
88
+ * Checks if token has expired based on elapsed time
89
+ * @function checkTokenExpiration
90
+ * @param {number} startTime - Start time in milliseconds
91
+ * @param {number} expiresIn - Expiration time in seconds
92
+ * @throws {Error} If token has expired
93
+ */
94
+ function checkTokenExpiration(startTime, expiresIn) {
95
+ const maxWaitTime = (expiresIn + 30) * 1000;
96
+ if (Date.now() - startTime > maxWaitTime) {
97
+ throw new Error('Device code expired: Maximum polling time exceeded');
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Parses token response from API
103
+ * Matches OpenAPI DeviceCodeTokenResponse schema (camelCase)
104
+ * @function parseTokenResponse
105
+ * @param {Object} response - API response object
106
+ * @returns {Object|null} Parsed token response or null if pending
107
+ */
108
+ function parseTokenResponse(response) {
109
+ // OpenAPI spec: { success: boolean, data: DeviceCodeTokenResponse, timestamp: string }
110
+ const apiResponse = response.data;
111
+ const responseData = apiResponse.data || apiResponse;
112
+
113
+ const error = responseData.error || apiResponse.error;
114
+ if (error === 'authorization_pending' || error === 'slow_down') {
115
+ return null;
116
+ }
117
+
118
+ // OpenAPI spec uses camelCase: accessToken, refreshToken, expiresIn
119
+ const accessToken = responseData.accessToken;
120
+ const refreshToken = responseData.refreshToken;
121
+ const expiresIn = responseData.expiresIn || 3600;
122
+
123
+ if (!accessToken) {
124
+ throw new Error('Invalid token response: missing accessToken');
125
+ }
126
+
127
+ // Return in snake_case for internal consistency (used by existing code)
128
+ return {
129
+ access_token: accessToken,
130
+ refresh_token: refreshToken,
131
+ expires_in: expiresIn
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Handles polling errors
137
+ * @function handlePollingErrors
138
+ * @param {string} error - Error code
139
+ * @param {number} status - HTTP status code
140
+ * @throws {Error} For fatal errors
141
+ * @returns {boolean} True if should continue polling
142
+ */
143
+ function handlePollingErrors(error, status) {
144
+ if (error === 'authorization_pending' || status === 202) {
145
+ return true;
146
+ }
147
+
148
+ // Check error field first, then status code
149
+ if (error === 'authorization_declined') {
150
+ throw new Error('Authorization declined: User denied the request');
151
+ }
152
+
153
+ if (error === 'expired_token' || status === 410) {
154
+ throw new Error('Device code expired: Please restart the authentication process');
155
+ }
156
+
157
+ if (error === 'slow_down') {
158
+ return true;
159
+ }
160
+
161
+ throw new Error(`Token polling failed: ${error}`);
162
+ }
163
+
164
+ /**
165
+ * Waits for next polling interval
166
+ * @async
167
+ * @function waitForNextPoll
168
+ * @param {number} interval - Polling interval in seconds
169
+ * @param {boolean} slowDown - Whether to slow down
170
+ */
171
+ async function waitForNextPoll(interval, slowDown) {
172
+ const waitInterval = slowDown ? interval * 2 : interval;
173
+ await new Promise(resolve => setTimeout(resolve, waitInterval * 1000));
174
+ }
175
+
176
+ /**
177
+ * Extracts error from API response
178
+ * @param {Object} response - API response object
179
+ * @returns {string} Error code or 'Unknown error'
180
+ */
181
+ function extractPollingError(response) {
182
+ const apiResponse = response.data || {};
183
+ const errorData = typeof apiResponse === 'object' ? apiResponse : {};
184
+ return errorData.error || response.error || 'Unknown error';
185
+ }
186
+
187
+ /**
188
+ * Handles successful polling response
189
+ * @param {Object} response - API response object
190
+ * @returns {Object|null} Token response or null if pending
191
+ */
192
+ function handleSuccessfulPoll(response) {
193
+ const tokenResponse = parseTokenResponse(response);
194
+ if (tokenResponse) {
195
+ return tokenResponse;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * Processes polling response and determines next action
202
+ * @async
203
+ * @function processPollingResponse
204
+ * @param {Object} response - API response object
205
+ * @param {number} interval - Polling interval in seconds
206
+ * @returns {Promise<Object|null>} Token response if complete, null if should continue
207
+ */
208
+ async function processPollingResponse(response, interval) {
209
+ if (response.success) {
210
+ const tokenResponse = handleSuccessfulPoll(response);
211
+ if (tokenResponse) {
212
+ return tokenResponse;
213
+ }
214
+
215
+ const apiResponse = response.data;
216
+ const responseData = apiResponse.data || apiResponse;
217
+ const error = responseData.error || apiResponse.error;
218
+ const slowDown = error === 'slow_down';
219
+ await waitForNextPoll(interval, slowDown);
220
+ return null;
221
+ }
222
+
223
+ const error = extractPollingError(response);
224
+ const shouldContinue = handlePollingErrors(error, response.status);
225
+
226
+ if (shouldContinue) {
227
+ const slowDown = error === 'slow_down';
228
+ await waitForNextPoll(interval, slowDown);
229
+ return null;
230
+ }
231
+
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Polls for token during Device Code Flow
237
+ * Continuously polls the token endpoint until user approves or flow expires
238
+ *
239
+ * @async
240
+ * @function pollDeviceCodeToken
241
+ * @param {string} controllerUrl - Base URL of the controller
242
+ * @param {string} deviceCode - Device code from initiation
243
+ * @param {number} interval - Polling interval in seconds
244
+ * @param {number} expiresIn - Expiration time in seconds
245
+ * @param {Function} [onPoll] - Optional callback called on each poll attempt
246
+ * @returns {Promise<Object>} Token response with access_token, refresh_token, expires_in
247
+ * @throws {Error} If polling fails or token is expired/declined
248
+ */
249
+ async function pollDeviceCodeToken(controllerUrl, deviceCode, interval, expiresIn, onPoll) {
250
+ if (!deviceCode || typeof deviceCode !== 'string') {
251
+ throw new Error('Device code is required');
252
+ }
253
+
254
+ const url = `${controllerUrl}/api/v1/auth/login/device/token`;
255
+ const startTime = Date.now();
256
+
257
+ // eslint-disable-next-line no-constant-condition
258
+ while (true) {
259
+ checkTokenExpiration(startTime, expiresIn);
260
+
261
+ if (onPoll) {
262
+ onPoll();
263
+ }
264
+
265
+ const response = await getMakeApiCall()(url, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json'
269
+ },
270
+ body: JSON.stringify({
271
+ deviceCode: deviceCode
272
+ })
273
+ });
274
+
275
+ const tokenResponse = await processPollingResponse(response, interval);
276
+ if (tokenResponse) {
277
+ return tokenResponse;
278
+ }
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Displays device code information to the user
284
+ * Formats user code and verification URL for easy reading
285
+ *
286
+ * @function displayDeviceCodeInfo
287
+ * @param {string} userCode - User code to display
288
+ * @param {string} verificationUri - Verification URL
289
+ * @param {Object} logger - Logger instance with log method
290
+ * @param {Object} chalk - Chalk instance for colored output
291
+ */
292
+ function displayDeviceCodeInfo(userCode, verificationUri, logger, chalk) {
293
+ logger.log(chalk.cyan('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
294
+ logger.log(chalk.cyan(' Device Code Flow Authentication'));
295
+ logger.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
296
+ logger.log(chalk.yellow('To complete authentication:'));
297
+ logger.log(chalk.gray(' 1. Visit: ') + chalk.blue.underline(verificationUri));
298
+ logger.log(chalk.gray(' 2. Enter code: ') + chalk.bold.cyan(userCode));
299
+ logger.log(chalk.gray(' 3. Approve the request\n'));
300
+ logger.log(chalk.gray('Waiting for approval...'));
301
+ }
302
+
303
+ /**
304
+ * Refresh device code access token using refresh token
305
+ * Uses OpenAPI /api/v1/auth/login/device/refresh endpoint
306
+ *
307
+ * @async
308
+ * @function refreshDeviceToken
309
+ * @param {string} controllerUrl - Base URL of the controller
310
+ * @param {string} refreshToken - Refresh token from previous authentication
311
+ * @returns {Promise<Object>} Token response with access_token, refresh_token, expires_in
312
+ * @throws {Error} If refresh fails or refresh token is invalid/expired
313
+ */
314
+ async function refreshDeviceToken(controllerUrl, refreshToken) {
315
+ if (!refreshToken || typeof refreshToken !== 'string') {
316
+ throw new Error('Refresh token is required');
317
+ }
318
+
319
+ const url = `${controllerUrl}/api/v1/auth/login/device/refresh`;
320
+ const response = await makeApiCall(url, {
321
+ method: 'POST',
322
+ headers: {
323
+ 'Content-Type': 'application/json'
324
+ },
325
+ body: JSON.stringify({ refreshToken })
326
+ });
327
+
328
+ if (!response.success) {
329
+ const errorMsg = response.error || 'Unknown error';
330
+ throw new Error(`Failed to refresh token: ${errorMsg}`);
331
+ }
332
+
333
+ // Parse response using existing parseTokenResponse function
334
+ const tokenResponse = parseTokenResponse(response);
335
+ if (!tokenResponse) {
336
+ throw new Error('Invalid refresh token response');
337
+ }
338
+
339
+ return tokenResponse;
340
+ }
341
+
342
+ module.exports = {
343
+ initiateDeviceCodeFlow,
344
+ pollDeviceCodeToken,
345
+ displayDeviceCodeInfo,
346
+ refreshDeviceToken,
347
+ parseTokenResponse
348
+ };
349
+
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Environment config loader
3
+ *
4
+ * @fileoverview Loads lib/schema/env-config.yaml for env variable interpolation
5
+ * Merges with user's env-config file if configured in ~/.aifabrix/config.yaml
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+ const config = require('../config');
16
+
17
+ /**
18
+ * Loads user env-config file if configured
19
+ * @async
20
+ * @function loadUserEnvConfig
21
+ * @returns {Promise<Object|null>} Parsed user env-config or null if not configured
22
+ */
23
+ async function loadUserEnvConfig() {
24
+ try {
25
+ const userEnvConfigPath = await config.getAifabrixEnvConfigPath();
26
+ if (!userEnvConfigPath) {
27
+ return null;
28
+ }
29
+
30
+ // Resolve path (support absolute and relative paths)
31
+ const resolvedPath = path.isAbsolute(userEnvConfigPath)
32
+ ? userEnvConfigPath
33
+ : path.resolve(process.cwd(), userEnvConfigPath);
34
+
35
+ if (!fs.existsSync(resolvedPath)) {
36
+ return null;
37
+ }
38
+
39
+ const content = fs.readFileSync(resolvedPath, 'utf8');
40
+ const userConfig = yaml.load(content);
41
+ return userConfig && typeof userConfig === 'object' ? userConfig : null;
42
+ } catch (error) {
43
+ // Gracefully handle errors - fallback to base config only
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Merges user env-config with base env-config
50
+ * User values override/extend base values
51
+ * @function mergeEnvConfigs
52
+ * @param {Object} baseConfig - Base env-config from lib/schema/env-config.yaml
53
+ * @param {Object|null} userConfig - User env-config or null
54
+ * @returns {Object} Merged env-config
55
+ */
56
+ function mergeEnvConfigs(baseConfig, userConfig) {
57
+ if (!userConfig || typeof userConfig !== 'object') {
58
+ return baseConfig || {};
59
+ }
60
+
61
+ // Deep merge environments
62
+ const merged = { ...baseConfig };
63
+ if (userConfig.environments && typeof userConfig.environments === 'object') {
64
+ merged.environments = { ...(baseConfig.environments || {}) };
65
+ for (const [env, envVars] of Object.entries(userConfig.environments)) {
66
+ if (envVars && typeof envVars === 'object') {
67
+ merged.environments[env] = {
68
+ ...(merged.environments[env] || {}),
69
+ ...envVars
70
+ };
71
+ }
72
+ }
73
+ }
74
+
75
+ return merged;
76
+ }
77
+
78
+ /**
79
+ * Load env config YAML used for environment variable interpolation
80
+ * Loads base config from lib/schema/env-config.yaml and merges with user config if configured
81
+ * @async
82
+ * @function loadEnvConfig
83
+ * @returns {Promise<Object>} Parsed and merged env-config YAML
84
+ * @throws {Error} If base file cannot be read or parsed
85
+ */
86
+ async function loadEnvConfig() {
87
+ // Load base env-config.yaml
88
+ const envConfigPath = path.join(__dirname, '..', 'schema', 'env-config.yaml');
89
+ const content = fs.readFileSync(envConfigPath, 'utf8');
90
+ const baseConfig = yaml.load(content) || {};
91
+
92
+ // Load user env-config if configured
93
+ const userConfig = await loadUserEnvConfig();
94
+
95
+ // Merge user config with base (user overrides/extends base)
96
+ return mergeEnvConfigs(baseConfig, userConfig);
97
+ }
98
+
99
+ module.exports = {
100
+ loadEnvConfig
101
+ };
102
+
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Environment copy and port update utilities
3
+ *
4
+ * @fileoverview Copy .env to app output and apply local/dockerside port rules with dev offsets
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const yaml = require('js-yaml');
14
+ const chalk = require('chalk');
15
+ const logger = require('./logger');
16
+ const config = require('../config');
17
+ const devConfig = require('../utils/dev-config');
18
+ const { rewriteInfraEndpoints } = require('./env-endpoints');
19
+
20
+ /**
21
+ * Process and optionally copy env file to envOutputPath if configured
22
+ * Regenerates .env file with env=local for local development (apps/.env)
23
+ * @async
24
+ * @function processEnvVariables
25
+ * @param {string} envPath - Path to generated .env file
26
+ * @param {string} variablesPath - Path to variables.yaml
27
+ * @param {string} appName - Application name (for regenerating with local env)
28
+ * @param {string} [secretsPath] - Path to secrets file (optional, for regenerating)
29
+ */
30
+ async function processEnvVariables(envPath, variablesPath, appName, secretsPath) {
31
+ if (!fs.existsSync(variablesPath)) {
32
+ return;
33
+ }
34
+ const variablesContent = fs.readFileSync(variablesPath, 'utf8');
35
+ const variables = yaml.load(variablesContent);
36
+ if (!variables?.build?.envOutputPath || variables.build.envOutputPath === null) {
37
+ return;
38
+ }
39
+ // Resolve output path: absolute stays as-is; relative is resolved against variables.yaml directory
40
+ const rawOutputPath = variables.build.envOutputPath;
41
+ let outputPath;
42
+ if (path.isAbsolute(rawOutputPath)) {
43
+ outputPath = rawOutputPath;
44
+ } else {
45
+ const variablesDir = path.dirname(variablesPath);
46
+ outputPath = path.resolve(variablesDir, rawOutputPath);
47
+ }
48
+ if (!outputPath.endsWith('.env')) {
49
+ if (fs.existsSync(outputPath) && fs.statSync(outputPath).isDirectory()) {
50
+ outputPath = path.join(outputPath, '.env');
51
+ } else {
52
+ outputPath = path.join(outputPath, '.env');
53
+ }
54
+ }
55
+ const outputDir = path.dirname(outputPath);
56
+ if (!fs.existsSync(outputDir)) {
57
+ fs.mkdirSync(outputDir, { recursive: true });
58
+ }
59
+
60
+ // Regenerate .env file with env=local instead of copying docker-generated file
61
+ // This ensures all variables use localhost instead of docker service names
62
+ if (appName) {
63
+ const { generateEnvContent } = require('../secrets');
64
+ // Generate local .env content (without writing to builder/.env to avoid overwriting docker version)
65
+ const localEnvContent = await generateEnvContent(appName, secretsPath, 'local', false);
66
+ // Write to output path
67
+ fs.writeFileSync(outputPath, localEnvContent, { mode: 0o600 });
68
+ logger.log(chalk.green(`✓ Generated local .env at: ${variables.build.envOutputPath}`));
69
+ } else {
70
+ // Fallback: if appName not provided, use old patching approach
71
+ let envContent = fs.readFileSync(envPath, 'utf8');
72
+ // Determine base app port and compute developer-specific app port
73
+ const baseAppPort = variables.build?.localPort || variables.port || 3000;
74
+ const devIdRaw = process.env.AIFABRIX_DEVELOPERID;
75
+ // Best effort: parse from env first, otherwise rely on config (may throw if async, so guarded below)
76
+ let devIdNum = Number.isFinite(parseInt(devIdRaw, 10)) ? parseInt(devIdRaw, 10) : null;
77
+ try {
78
+ if (devIdNum === null) {
79
+ // Try to read developer-id from config file synchronously if present
80
+ const configPath = config && config.CONFIG_FILE ? config.CONFIG_FILE : null;
81
+ if (configPath && fs.existsSync(configPath)) {
82
+ try {
83
+ const cfgContent = fs.readFileSync(configPath, 'utf8');
84
+ const cfg = yaml.load(cfgContent) || {};
85
+ const raw = cfg['developer-id'];
86
+ if (typeof raw === 'number') {
87
+ devIdNum = raw;
88
+ } else if (typeof raw === 'string' && /^[0-9]+$/.test(raw)) {
89
+ devIdNum = parseInt(raw, 10);
90
+ }
91
+ } catch {
92
+ // ignore, will fallback to 0
93
+ }
94
+ }
95
+ if (devIdNum === null || Number.isNaN(devIdNum)) {
96
+ devIdNum = 0;
97
+ }
98
+ }
99
+ } catch {
100
+ devIdNum = 0;
101
+ }
102
+ const appPort = devIdNum === 0 ? baseAppPort : (baseAppPort + (devIdNum * 100));
103
+ const infraPorts = devConfig.getDevPorts(devIdNum);
104
+
105
+ // Update PORT (replace or append)
106
+ if (/^PORT\s*=.*$/m.test(envContent)) {
107
+ envContent = envContent.replace(/^PORT\s*=\s*.*$/m, `PORT=${appPort}`);
108
+ } else {
109
+ envContent = `${envContent}\nPORT=${appPort}\n`;
110
+ }
111
+
112
+ // Update localhost URLs that point to the base app port to the dev-specific app port
113
+ const localhostUrlPattern = /(https?:\/\/localhost:)(\d+)(\b[^ \n]*)?/g;
114
+ envContent = 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
+ // Rewrite infra endpoints using env-config mapping for local context
122
+ envContent = await rewriteInfraEndpoints(envContent, 'local', infraPorts);
123
+ fs.writeFileSync(outputPath, envContent, { mode: 0o600 });
124
+ logger.log(chalk.green(`✓ Copied .env to: ${variables.build.envOutputPath}`));
125
+ }
126
+ }
127
+
128
+ module.exports = {
129
+ processEnvVariables
130
+ };
131
+