@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
@@ -9,6 +9,13 @@
9
9
  * @version 2.0.0
10
10
  */
11
11
 
12
+ const {
13
+ parseDeviceCodeResponse,
14
+ parseTokenResponse,
15
+ checkTokenExpiration,
16
+ processPollingResponse
17
+ } = require('./device-code-helpers');
18
+
12
19
  // Lazy require to avoid circular dependency
13
20
  let makeApiCall;
14
21
  function getMakeApiCall() {
@@ -19,40 +26,6 @@ function getMakeApiCall() {
19
26
  return makeApiCall;
20
27
  }
21
28
 
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
29
  /**
57
30
  * Initiates OAuth2 Device Code Flow
58
31
  * Calls the device code endpoint to get device_code and user_code
@@ -70,7 +43,6 @@ async function initiateDeviceCodeFlow(controllerUrl, environment, scope) {
70
43
  throw new Error('Environment key is required');
71
44
  }
72
45
 
73
- // Default scope for backward compatibility
74
46
  const defaultScope = 'openid profile email';
75
47
  const requestScope = scope || defaultScope;
76
48
 
@@ -92,298 +64,6 @@ async function initiateDeviceCodeFlow(controllerUrl, environment, scope) {
92
64
  return parseDeviceCodeResponse(response);
93
65
  }
94
66
 
95
- /**
96
- * Checks if token has expired based on elapsed time
97
- * @function checkTokenExpiration
98
- * @param {number} startTime - Start time in milliseconds
99
- * @param {number} expiresIn - Expiration time in seconds
100
- * @throws {Error} If token has expired
101
- */
102
- function checkTokenExpiration(startTime, expiresIn) {
103
- const maxWaitTime = (expiresIn + 30) * 1000;
104
- if (Date.now() - startTime > maxWaitTime) {
105
- throw new Error('Device code expired: Maximum polling time exceeded');
106
- }
107
- }
108
-
109
- /**
110
- * Parses token response from API
111
- * Matches OpenAPI DeviceCodeTokenResponse schema (camelCase)
112
- * @function parseTokenResponse
113
- * @param {Object} response - API response object
114
- * @returns {Object|null} Parsed token response or null if pending
115
- */
116
- function parseTokenResponse(response) {
117
- // OpenAPI spec: { success: boolean, data: DeviceCodeTokenResponse, timestamp: string }
118
- const apiResponse = response.data;
119
- const responseData = apiResponse.data || apiResponse;
120
-
121
- const error = responseData.error || apiResponse.error;
122
- if (error === 'authorization_pending' || error === 'slow_down') {
123
- return null;
124
- }
125
-
126
- // OpenAPI spec uses camelCase: accessToken, refreshToken, expiresIn
127
- const accessToken = responseData.accessToken;
128
- const refreshToken = responseData.refreshToken;
129
- const expiresIn = responseData.expiresIn || 3600;
130
-
131
- if (!accessToken) {
132
- throw new Error('Invalid token response: missing accessToken');
133
- }
134
-
135
- // Return in snake_case for internal consistency (used by existing code)
136
- return {
137
- access_token: accessToken,
138
- refresh_token: refreshToken,
139
- expires_in: expiresIn
140
- };
141
- }
142
-
143
- /**
144
- * Creates a validation error with detailed information
145
- * @function createValidationError
146
- * @param {Object} response - Full API response object
147
- * @returns {Error} Validation error with formattedError and errorData attached
148
- */
149
- /**
150
- * Attaches formatted error to validation error
151
- * @function attachFormattedError
152
- * @param {Error} validationError - Validation error object
153
- * @param {Object} response - API response
154
- */
155
- function attachFormattedError(validationError, response) {
156
- if (response && response.formattedError) {
157
- validationError.formattedError = response.formattedError;
158
- validationError.message = `Token polling failed:\n${response.formattedError}`;
159
- }
160
- }
161
-
162
- /**
163
- * Builds detailed error message from error data
164
- * @function buildDetailedErrorMessage
165
- * @param {Object} errorData - Error data object
166
- * @returns {string} Detailed error message
167
- */
168
- function buildDetailedErrorMessage(errorData) {
169
- const detail = errorData.detail || errorData.title || errorData.message || 'Validation error';
170
- let errorMsg = `Token polling failed: ${detail}`;
171
-
172
- if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
173
- errorMsg += '\n\nValidation errors:';
174
- errorData.errors.forEach(err => {
175
- const field = err.field || err.path || 'validation';
176
- const message = err.message || 'Invalid value';
177
- if (field === 'validation' || field === 'unknown') {
178
- errorMsg += `\n • ${message}`;
179
- } else {
180
- errorMsg += `\n • ${field}: ${message}`;
181
- }
182
- });
183
- }
184
-
185
- return errorMsg;
186
- }
187
-
188
- /**
189
- * Attaches error data to validation error
190
- * @function attachErrorData
191
- * @param {Error} validationError - Validation error object
192
- * @param {Object} response - API response
193
- */
194
- function attachErrorData(validationError, response) {
195
- if (response && response.errorData) {
196
- validationError.errorData = response.errorData;
197
- validationError.errorType = response.errorType || 'validation';
198
-
199
- if (!validationError.formattedError) {
200
- validationError.message = buildDetailedErrorMessage(response.errorData);
201
- }
202
- }
203
- }
204
-
205
- function createValidationError(response) {
206
- const validationError = new Error('Token polling failed: Validation error');
207
-
208
- attachFormattedError(validationError, response);
209
- attachErrorData(validationError, response);
210
-
211
- return validationError;
212
- }
213
-
214
- /**
215
- * Handles polling errors
216
- * @function handlePollingErrors
217
- * @param {string} error - Error code
218
- * @param {number} status - HTTP status code
219
- * @param {Object} response - Full API response object (for accessing formattedError and errorData)
220
- * @throws {Error} For fatal errors
221
- * @returns {boolean} True if should continue polling
222
- */
223
- function handlePollingErrors(error, status, response) {
224
- if (error === 'authorization_pending' || status === 202) {
225
- return true;
226
- }
227
-
228
- // Check error field first, then status code
229
- if (error === 'authorization_declined') {
230
- throw new Error('Authorization declined: User denied the request');
231
- }
232
-
233
- if (error === 'expired_token' || status === 410) {
234
- throw new Error('Device code expired: Please restart the authentication process');
235
- }
236
-
237
- if (error === 'slow_down') {
238
- return true;
239
- }
240
-
241
- // Handle validation errors with detailed message
242
- // Check for validation_error, status 400, or specific validation error codes
243
- if (error === 'validation_error' || status === 400 ||
244
- error === 'INVALID_TOKEN' || error === 'INVALID_ACCESS_TOKEN') {
245
- throw createValidationError(response);
246
- }
247
-
248
- throw new Error(`Token polling failed: ${error}`);
249
- }
250
-
251
- /**
252
- * Waits for next polling interval
253
- * @async
254
- * @function waitForNextPoll
255
- * @param {number} interval - Polling interval in seconds
256
- * @param {boolean} slowDown - Whether to slow down
257
- */
258
- async function waitForNextPoll(interval, slowDown) {
259
- const waitInterval = slowDown ? interval * 2 : interval;
260
- await new Promise(resolve => setTimeout(resolve, waitInterval * 1000));
261
- }
262
-
263
- /**
264
- * Extracts error from API response
265
- * @param {Object} response - API response object
266
- * @returns {string} Error code or 'Unknown error'
267
- */
268
- /**
269
- * Checks if error code indicates validation error
270
- * @function isValidationErrorCode
271
- * @param {string} errorCode - Error code to check
272
- * @returns {boolean} True if validation error
273
- */
274
- function isValidationErrorCode(errorCode) {
275
- return errorCode === 'INVALID_TOKEN' || errorCode === 'INVALID_ACCESS_TOKEN';
276
- }
277
-
278
- /**
279
- * Extracts error from structured error data
280
- * @function extractStructuredError
281
- * @param {Object} response - API response with errorData
282
- * @returns {string} Error message or code
283
- */
284
- function extractStructuredError(response) {
285
- const errorData = response.errorData;
286
-
287
- // For validation errors, return the error type so we can handle it specially
288
- if (response.errorType === 'validation') {
289
- return 'validation_error';
290
- }
291
-
292
- // Check if error code indicates validation error (e.g., INVALID_TOKEN)
293
- const errorCode = errorData.error || errorData.code || response.error;
294
- if (isValidationErrorCode(errorCode)) {
295
- return 'validation_error';
296
- }
297
-
298
- // Return the error message from structured error
299
- return errorData.detail || errorData.title || errorData.message || errorCode || response.error || 'Unknown error';
300
- }
301
-
302
- /**
303
- * Extracts error from fallback response structure
304
- * @function extractFallbackError
305
- * @param {Object} response - API response
306
- * @returns {string} Error code
307
- */
308
- function extractFallbackError(response) {
309
- const apiResponse = response.data || {};
310
- const errorData = typeof apiResponse === 'object' ? apiResponse : {};
311
- const errorCode = errorData.error || response.error || 'Unknown error';
312
-
313
- // Check if error code indicates validation error (e.g., INVALID_TOKEN)
314
- if (isValidationErrorCode(errorCode)) {
315
- return 'validation_error';
316
- }
317
-
318
- return errorCode;
319
- }
320
-
321
- function extractPollingError(response) {
322
- // Check for structured error data first (from api-error-handler)
323
- if (response.errorData) {
324
- return extractStructuredError(response);
325
- }
326
-
327
- // Fallback to original extraction logic
328
- return extractFallbackError(response);
329
- }
330
-
331
- /**
332
- * Handles successful polling response
333
- * @param {Object} response - API response object
334
- * @returns {Object|null} Token response or null if pending
335
- */
336
- function handleSuccessfulPoll(response) {
337
- const tokenResponse = parseTokenResponse(response);
338
- if (tokenResponse) {
339
- return tokenResponse;
340
- }
341
- return null;
342
- }
343
-
344
- /**
345
- * Processes polling response and determines next action
346
- * @async
347
- * @function processPollingResponse
348
- * @param {Object} response - API response object
349
- * @param {number} interval - Polling interval in seconds
350
- * @returns {Promise<Object|null>} Token response if complete, null if should continue
351
- */
352
- async function processPollingResponse(response, interval) {
353
- if (response.success) {
354
- // Check if response contains an error code even though success is true
355
- const apiResponse = response.data || {};
356
- const responseData = apiResponse.data || apiResponse;
357
- const errorCode = responseData.error || apiResponse.error || response.error;
358
-
359
- // If there's an error code like INVALID_TOKEN, treat it as a validation error
360
- if (errorCode && (errorCode === 'INVALID_TOKEN' || errorCode === 'INVALID_ACCESS_TOKEN')) {
361
- throw createValidationError(response);
362
- }
363
-
364
- const tokenResponse = handleSuccessfulPoll(response);
365
- if (tokenResponse) {
366
- return tokenResponse;
367
- }
368
-
369
- const error = errorCode;
370
- const slowDown = error === 'slow_down';
371
- await waitForNextPoll(interval, slowDown);
372
- return null;
373
- }
374
-
375
- const error = extractPollingError(response);
376
- const shouldContinue = handlePollingErrors(error, response.status, response);
377
-
378
- if (shouldContinue) {
379
- const slowDown = error === 'slow_down';
380
- await waitForNextPoll(interval, slowDown);
381
- return null;
382
- }
383
-
384
- return null;
385
- }
386
-
387
67
  /**
388
68
  * Polls for token during Device Code Flow
389
69
  * Continuously polls the token endpoint until user approves or flow expires
@@ -431,27 +111,47 @@ async function pollDeviceCodeToken(controllerUrl, deviceCode, interval, expiresI
431
111
  }
432
112
  }
433
113
 
114
+ /**
115
+ * Builds verification URL with user_code query parameter so the device page can pre-fill the code.
116
+ *
117
+ * @function buildVerificationUrlWithUserCode
118
+ * @param {string} verificationUri - Base verification URL (e.g. http://localhost:8182/realms/aifabrix/device)
119
+ * @param {string} userCode - User code to append as user_code query param
120
+ * @returns {string} Full URL with ?user_code=<code> or &user_code=<code>
121
+ */
122
+ function buildVerificationUrlWithUserCode(verificationUri, userCode) {
123
+ if (!verificationUri || !userCode) {
124
+ return verificationUri || '';
125
+ }
126
+ const separator = verificationUri.includes('?') ? '&' : '?';
127
+ return `${verificationUri}${separator}user_code=${encodeURIComponent(userCode)}`;
128
+ }
129
+
434
130
  /**
435
131
  * Displays device code information to the user
436
- * Formats user code and verification URL for easy reading
132
+ * Formats user code and verification URL for easy reading. Uses a URL with user_code in the query
133
+ * so the device page can pre-fill the code and the user does not need to type it.
437
134
  *
438
135
  * @function displayDeviceCodeInfo
439
136
  * @param {string} userCode - User code to display
440
- * @param {string} verificationUri - Verification URL
137
+ * @param {string} verificationUri - Verification URL (base, without user_code)
441
138
  * @param {Object} logger - Logger instance with log method
442
139
  * @param {Object} chalk - Chalk instance for colored output
443
140
  */
444
141
  function displayDeviceCodeInfo(userCode, verificationUri, logger, chalk) {
142
+ const visitUrl = buildVerificationUrlWithUserCode(verificationUri, userCode);
445
143
  logger.log(chalk.cyan('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
446
144
  logger.log(chalk.cyan(' Device Code Flow Authentication'));
447
145
  logger.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'));
448
146
  logger.log(chalk.yellow('To complete authentication:'));
449
- logger.log(chalk.gray(' 1. Visit: ') + chalk.blue.underline(verificationUri));
450
- logger.log(chalk.gray(' 2. Enter code: ') + chalk.bold.cyan(userCode));
451
- logger.log(chalk.gray(' 3. Approve the request\n'));
147
+ logger.log(chalk.gray(' 1. Visit (code is in the URL): ') + chalk.blue.underline(visitUrl));
148
+ logger.log(chalk.gray(' 2. Approve the request\n'));
452
149
  logger.log(chalk.gray('Waiting for approval...'));
453
150
  }
454
151
 
152
+ /** Timeout for token refresh request (ms). Longer than default to allow for slow controller/Keycloak. */
153
+ const REFRESH_TOKEN_TIMEOUT_MS = 60000;
154
+
455
155
  /**
456
156
  * Refresh device code access token using refresh token
457
157
  * Uses OpenAPI /api/v1/auth/login/device/refresh endpoint
@@ -469,13 +169,13 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
469
169
  }
470
170
 
471
171
  const url = `${controllerUrl}/api/v1/auth/login/device/refresh`;
472
- // Send both refresh_token (OAuth2 RFC 6749 / Keycloak) and refreshToken (camelCase) so controller accepts either
473
172
  const response = await getMakeApiCall()(url, {
474
173
  method: 'POST',
475
174
  headers: {
476
175
  'Content-Type': 'application/json'
477
176
  },
478
- body: JSON.stringify({ refresh_token: refreshToken, refreshToken })
177
+ body: JSON.stringify({ refresh_token: refreshToken, refreshToken }),
178
+ signal: AbortSignal.timeout(REFRESH_TOKEN_TIMEOUT_MS)
479
179
  });
480
180
 
481
181
  if (!response.success) {
@@ -483,7 +183,6 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
483
183
  throw new Error(`Failed to refresh token: ${errorMsg}`);
484
184
  }
485
185
 
486
- // Parse response using existing parseTokenResponse function
487
186
  const tokenResponse = parseTokenResponse(response);
488
187
  if (!tokenResponse) {
489
188
  throw new Error('Invalid refresh token response');
@@ -491,10 +190,12 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
491
190
 
492
191
  return tokenResponse;
493
192
  }
193
+
494
194
  module.exports = {
495
195
  initiateDeviceCodeFlow,
496
196
  pollDeviceCodeToken,
497
197
  displayDeviceCodeInfo,
498
198
  refreshDeviceToken,
499
- parseTokenResponse
199
+ parseTokenResponse,
200
+ buildVerificationUrlWithUserCode
500
201
  };
@@ -95,9 +95,19 @@ function handleDockerClose(code, ctx) {
95
95
  }
96
96
 
97
97
  function runDockerBuildProcess(buildOpts) {
98
- const { imageName, tag, dockerfilePath, contextPath, spinner, resolve, reject } = buildOpts;
99
- const dockerProcess = spawn('docker', ['build', '-t', `${imageName}:${tag}`, '-f', dockerfilePath, contextPath], {
100
- shell: process.platform === 'win32'
98
+ const { imageName, tag, dockerfilePath, contextPath, spinner, resolve, reject, env = {}, buildArgs = {} } = buildOpts;
99
+ const spawnEnv = { ...process.env, ...env };
100
+ const args = ['build', '-t', `${imageName}:${tag}`, '-f', dockerfilePath];
101
+ // Pass NPM_TOKEN/PYPI_TOKEN etc. so private registry auth works during RUN npm install / pip install
102
+ for (const [key, value] of Object.entries(buildArgs)) {
103
+ if (value !== null && value !== undefined && String(value).length > 0) {
104
+ args.push('--build-arg', `${key}=${String(value)}`);
105
+ }
106
+ }
107
+ args.push(contextPath);
108
+ const dockerProcess = spawn('docker', args, {
109
+ shell: process.platform === 'win32',
110
+ env: spawnEnv
101
111
  });
102
112
  let stdoutBuffer = '';
103
113
  let stderrBuffer = '';
@@ -138,13 +148,15 @@ function runDockerBuildProcess(buildOpts) {
138
148
  * @param {string} dockerfilePath - Path to Dockerfile
139
149
  * @param {string} contextPath - Build context path
140
150
  * @param {string} tag - Image tag
151
+ * @param {Object} [buildArgs={}] - Optional build args (e.g. NPM_TOKEN, PYPI_TOKEN) for private registries
141
152
  * @returns {Promise<void>} Resolves when build completes
142
153
  * @throws {Error} If build fails
143
154
  */
144
- async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
155
+ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs = {}) {
145
156
  const spinner = ora({ text: 'Starting Docker build...', spinner: 'dots' }).start();
146
157
  const fsSync = require('fs');
147
158
  const path = require('path');
159
+ const { getRemoteDockerEnv } = require('./remote-docker-env');
148
160
  dockerfilePath = path.resolve(dockerfilePath);
149
161
  contextPath = path.resolve(contextPath);
150
162
 
@@ -162,8 +174,21 @@ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
162
174
  }
163
175
  }
164
176
 
177
+ const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
178
+ const remoteEnv = isTest ? {} : await getRemoteDockerEnv();
179
+ const resolvedBuildArgs = buildArgs && typeof buildArgs === 'object' ? buildArgs : {};
165
180
  return new Promise((resolve, reject) => {
166
- runDockerBuildProcess({ imageName, tag, dockerfilePath, contextPath, spinner, resolve, reject });
181
+ runDockerBuildProcess({
182
+ imageName,
183
+ tag,
184
+ dockerfilePath,
185
+ contextPath,
186
+ spinner,
187
+ resolve,
188
+ reject,
189
+ env: remoteEnv,
190
+ buildArgs: resolvedBuildArgs
191
+ });
167
192
  });
168
193
  }
169
194
 
@@ -179,14 +204,18 @@ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
179
204
  * @throws {Error} If build fails
180
205
  */
181
206
  async function executeBuild(imageName, dockerfilePath, contextPath, tag, options) {
182
- await executeDockerBuild(imageName, dockerfilePath, contextPath, tag);
207
+ const buildArgs = (options && options.buildArgs) || {};
208
+ await executeDockerBuild(imageName, dockerfilePath, contextPath, tag, buildArgs);
183
209
 
184
210
  // Tag image if additional tag provided
185
211
  if (options && options.tag && options.tag !== 'latest') {
186
212
  const { promisify } = require('util');
187
213
  const { exec } = require('child_process');
188
214
  const run = promisify(exec);
189
- await run(`docker tag ${imageName}:${tag} ${imageName}:latest`);
215
+ const { getRemoteDockerEnv } = require('./remote-docker-env');
216
+ const remoteEnv = await getRemoteDockerEnv();
217
+ const env = { ...process.env, ...remoteEnv };
218
+ await run(`docker tag ${imageName}:${tag} ${imageName}:latest`, { env });
190
219
  }
191
220
  }
192
221
 
@@ -215,7 +244,10 @@ async function executeDockerBuildWithTag(effectiveImageName, imageName, dockerfi
215
244
  const { promisify } = require('util');
216
245
  const { exec } = require('child_process');
217
246
  const run = promisify(exec);
218
- await run(`docker tag ${effectiveImageName}:${tag} ${imageName}:${tag}`);
247
+ const { getRemoteDockerEnv } = require('./remote-docker-env');
248
+ const remoteEnv = await getRemoteDockerEnv();
249
+ const env = { ...process.env, ...remoteEnv };
250
+ await run(`docker tag ${effectiveImageName}:${tag} ${imageName}:${tag}`, { env });
219
251
  logger.log(chalk.green(`✓ Tagged image: ${imageName}:${tag}`));
220
252
  } catch (err) {
221
253
  logger.log(chalk.yellow(`⚠️ Warning: Could not create compatibility tag ${imageName}:${tag} - ${err.message}`));
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const fs = require('fs');
12
+ const fsp = require('fs').promises;
12
13
  const path = require('path');
13
14
  const yaml = require('js-yaml');
14
15
  const chalk = require('chalk');
@@ -72,6 +73,44 @@ function resolveEnvOutputPath(rawOutputPath, variablesPath) {
72
73
  return outputPath;
73
74
  }
74
75
 
76
+ /**
77
+ * Writes .env to envOutputPath for reload path: merge run .env into existing file.
78
+ * @async
79
+ * @param {string} outputPath - Resolved output path
80
+ * @param {string} runEnvPath - Path to .env.run
81
+ */
82
+ async function writeEnvOutputForReload(outputPath, runEnvPath) {
83
+ const { parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
84
+ const runContent = await fsp.readFile(runEnvPath, 'utf8');
85
+ const runMap = parseEnvContentToMap(runContent);
86
+ let toWrite = runContent;
87
+ if (fs.existsSync(outputPath)) {
88
+ const existingContent = await fsp.readFile(outputPath, 'utf8');
89
+ toWrite = mergeEnvMapIntoContent(existingContent, runMap);
90
+ }
91
+ await fsp.writeFile(outputPath, toWrite, { mode: 0o600 });
92
+ logger.log(chalk.green(`✓ Wrote .env to envOutputPath (same as container, for --reload): ${outputPath}`));
93
+ }
94
+
95
+ /**
96
+ * Writes local .env to envOutputPath (no reload).
97
+ * @async
98
+ * @param {string} appName - Application name
99
+ * @param {string} outputPath - Resolved output path
100
+ */
101
+ async function writeEnvOutputForLocal(appName, outputPath) {
102
+ const { generateEnvContent, parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
103
+ const localContent = await generateEnvContent(appName, null, 'local', false);
104
+ let toWrite = localContent;
105
+ if (fs.existsSync(outputPath)) {
106
+ const existingContent = await fsp.readFile(outputPath, 'utf8');
107
+ const localMap = parseEnvContentToMap(localContent);
108
+ toWrite = mergeEnvMapIntoContent(existingContent, localMap);
109
+ }
110
+ await fsp.writeFile(outputPath, toWrite, { mode: 0o600 });
111
+ logger.log(chalk.green(`✓ Wrote .env to envOutputPath (localPort): ${outputPath}`));
112
+ }
113
+
75
114
  /**
76
115
  * Calculate developer-specific app port
77
116
  * @param {number} baseAppPort - Base application port
@@ -180,6 +219,42 @@ async function patchEnvContentForLocal(envContent, variables) {
180
219
  return envContent;
181
220
  }
182
221
 
222
+ /**
223
+ * Write regenerated local .env to output path (merge with existing if present).
224
+ * @async
225
+ * @param {string} outputPath - Resolved output path
226
+ * @param {string} appName - Application name
227
+ * @param {string} [secretsPath] - Path to secrets file (optional)
228
+ * @param {string} envOutputPathLabel - Label for log message (e.g. variables.build.envOutputPath)
229
+ */
230
+ async function writeLocalEnvToOutputPath(outputPath, appName, secretsPath, envOutputPathLabel) {
231
+ const { generateEnvContent, parseEnvContentToMap, mergeEnvMapIntoContent } = require('../core/secrets');
232
+ const localEnvContent = await generateEnvContent(appName, secretsPath, 'local', false);
233
+ let toWrite = localEnvContent;
234
+ if (fs.existsSync(outputPath)) {
235
+ const existingContent = fs.readFileSync(outputPath, 'utf8');
236
+ const localMap = parseEnvContentToMap(localEnvContent);
237
+ toWrite = mergeEnvMapIntoContent(existingContent, localMap);
238
+ }
239
+ fs.writeFileSync(outputPath, toWrite, { mode: 0o600 });
240
+ logger.log(chalk.green(`✓ Generated local .env at: ${envOutputPathLabel}`));
241
+ }
242
+
243
+ /**
244
+ * Write patched .env to output path (fallback when appName not provided).
245
+ * @async
246
+ * @param {string} envPath - Path to generated .env file
247
+ * @param {string} outputPath - Resolved output path
248
+ * @param {Object} variables - Loaded variables config
249
+ * @param {string} envOutputPathLabel - Label for log message
250
+ */
251
+ async function writePatchedEnvToOutputPath(envPath, outputPath, variables, envOutputPathLabel) {
252
+ const envContent = fs.readFileSync(envPath, 'utf8');
253
+ const patchedContent = await patchEnvContentForLocal(envContent, variables);
254
+ fs.writeFileSync(outputPath, patchedContent, { mode: 0o600 });
255
+ logger.log(chalk.green(`✓ Copied .env to: ${envOutputPathLabel}`));
256
+ }
257
+
183
258
  /**
184
259
  * Process and optionally copy env file to envOutputPath if configured
185
260
  * Regenerates .env file with env=local for local development (apps/.env)
@@ -191,7 +266,7 @@ async function patchEnvContentForLocal(envContent, variables) {
191
266
  * @param {string} [secretsPath] - Path to secrets file (optional, for regenerating)
192
267
  */
193
268
  async function processEnvVariables(envPath, variablesPath, appName, secretsPath) {
194
- if (!fs.existsSync(variablesPath)) {
269
+ if (!variablesPath || !fs.existsSync(variablesPath)) {
195
270
  return;
196
271
  }
197
272
  const variablesContent = fs.readFileSync(variablesPath, 'utf8');
@@ -200,29 +275,24 @@ async function processEnvVariables(envPath, variablesPath, appName, secretsPath)
200
275
  return;
201
276
  }
202
277
 
203
- // Resolve output path
204
278
  const outputPath = resolveEnvOutputPath(variables.build.envOutputPath, variablesPath);
205
279
  const outputDir = path.dirname(outputPath);
206
280
  if (!fs.existsSync(outputDir)) {
207
281
  fs.mkdirSync(outputDir, { recursive: true });
208
282
  }
209
283
 
210
- // Regenerate .env file with env=local instead of copying docker-generated file
284
+ const label = variables.build.envOutputPath;
211
285
  if (appName) {
212
- const { generateEnvContent } = require('../core/secrets');
213
- const localEnvContent = await generateEnvContent(appName, secretsPath, 'local', false);
214
- fs.writeFileSync(outputPath, localEnvContent, { mode: 0o600 });
215
- logger.log(chalk.green(`✓ Generated local .env at: ${variables.build.envOutputPath}`));
286
+ await writeLocalEnvToOutputPath(outputPath, appName, secretsPath, label);
216
287
  } else {
217
- // Fallback: if appName not provided, use old patching approach
218
- const envContent = fs.readFileSync(envPath, 'utf8');
219
- const patchedContent = await patchEnvContentForLocal(envContent, variables);
220
- fs.writeFileSync(outputPath, patchedContent, { mode: 0o600 });
221
- logger.log(chalk.green(`✓ Copied .env to: ${variables.build.envOutputPath}`));
288
+ await writePatchedEnvToOutputPath(envPath, outputPath, variables, label);
222
289
  }
223
290
  }
224
291
 
225
292
  module.exports = {
226
- processEnvVariables
293
+ processEnvVariables,
294
+ resolveEnvOutputPath,
295
+ writeEnvOutputForReload,
296
+ writeEnvOutputForLocal
227
297
  };
228
298