@aifabrix/builder 2.47.4 → 2.49.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 (93) hide show
  1. package/jest.isolated-projects.js +7 -0
  2. package/jest.projects.js +16 -0
  3. package/lib/api/applications.api.js +23 -0
  4. package/lib/api/dev.api.js +1 -1
  5. package/lib/api/index.js +5 -2
  6. package/lib/api/types/dev.types.js +4 -3
  7. package/lib/app/deploy-config.js +6 -4
  8. package/lib/app/register.js +21 -19
  9. package/lib/app/rotate-secret.js +39 -16
  10. package/lib/app/run-container-start.js +0 -9
  11. package/lib/app/run-helpers.js +8 -4
  12. package/lib/app/run-reload-sync.js +16 -18
  13. package/lib/app/run-stop-containers.js +48 -5
  14. package/lib/app/run.js +6 -3
  15. package/lib/app/show.js +2 -2
  16. package/lib/build/index.js +12 -49
  17. package/lib/build/integration-docker-staging.js +35 -0
  18. package/lib/build/standard-docker-build.js +113 -0
  19. package/lib/cli/infra-guided-auth.js +42 -6
  20. package/lib/cli/infra-guided-footers.js +164 -0
  21. package/lib/cli/infra-guided-spinners.js +57 -0
  22. package/lib/cli/infra-guided.js +41 -179
  23. package/lib/cli/setup-app.js +5 -1
  24. package/lib/cli/setup-dev.js +3 -2
  25. package/lib/cli/setup-infra.js +3 -2
  26. package/lib/cli/setup-platform.js +3 -0
  27. package/lib/commands/dev-init.js +18 -0
  28. package/lib/commands/login-device.js +6 -2
  29. package/lib/commands/login.js +4 -1
  30. package/lib/commands/setup-modes.js +19 -3
  31. package/lib/commands/setup-platform-auth.js +25 -0
  32. package/lib/commands/setup-prompts-admin.js +9 -0
  33. package/lib/commands/setup-prompts-platform-mode.js +11 -1
  34. package/lib/commands/setup.js +60 -7
  35. package/lib/commands/up-dataplane-bootstrap.js +149 -0
  36. package/lib/commands/up-dataplane-credentials.js +104 -6
  37. package/lib/commands/up-dataplane.js +74 -11
  38. package/lib/core/secrets-env-declarative-expand.js +35 -23
  39. package/lib/core/setup-platform-mode.js +59 -9
  40. package/lib/deployment/deployer-status.js +26 -3
  41. package/lib/deployment/deployer.js +7 -14
  42. package/lib/external-system/download-helpers.js +10 -1
  43. package/lib/infrastructure/compose.js +40 -12
  44. package/lib/infrastructure/services.js +15 -3
  45. package/lib/infrastructure/traefik-tls-dynamic.js +88 -0
  46. package/lib/parameters/infra-kv-discovery.js +4 -3
  47. package/lib/utils/aifabrix-runtime-config-dir.js +2 -1
  48. package/lib/utils/app-bootstrap-api.js +53 -0
  49. package/lib/utils/app-run-containers.js +14 -3
  50. package/lib/utils/applications-config-defaults.js +19 -3
  51. package/lib/utils/compose-generate-docker-compose.js +18 -6
  52. package/lib/utils/compose-generator.js +34 -15
  53. package/lib/utils/compose-traefik-ingress-base.js +13 -14
  54. package/lib/utils/config-paths.js +8 -3
  55. package/lib/utils/dev-ca-docker-augment.js +54 -0
  56. package/lib/utils/dev-user-groups.js +1 -0
  57. package/lib/utils/docker-daemon-tls-ca.js +363 -0
  58. package/lib/utils/docker-reload-mount.js +12 -1
  59. package/lib/utils/ensure-dev-certs-for-remote-docker.js +5 -0
  60. package/lib/utils/external-env-template.js +53 -18
  61. package/lib/utils/health-check-poll-ui.js +84 -0
  62. package/lib/utils/health-check-url.js +54 -34
  63. package/lib/utils/health-check.js +43 -16
  64. package/lib/utils/infra-optional-service-flags.js +31 -2
  65. package/lib/utils/mutagen.js +7 -4
  66. package/lib/utils/platform-controller-url.js +23 -1
  67. package/lib/utils/promote-dev-docker-image.js +170 -0
  68. package/lib/utils/refresh-remote-settings.js +67 -0
  69. package/lib/utils/remote-docker-env.js +22 -7
  70. package/lib/utils/remote-host-for-front-door.js +43 -0
  71. package/lib/utils/run-cli-flags.js +37 -1
  72. package/lib/utils/secrets-helpers.js +9 -14
  73. package/lib/utils/secrets-kv-refs.js +47 -3
  74. package/lib/utils/secrets-missing-error.js +1 -9
  75. package/lib/utils/token-manager-client-credentials.js +11 -1
  76. package/lib/utils/url-declarative-public-base.js +11 -4
  77. package/lib/utils/url-declarative-resolve-build-urls.js +11 -2
  78. package/lib/utils/url-declarative-resolve-expand-token.js +12 -1
  79. package/lib/utils/url-declarative-resolve-load-doc.js +30 -11
  80. package/lib/utils/url-declarative-resolve.js +2 -0
  81. package/lib/utils/urls-local-registry-scan.js +95 -10
  82. package/lib/utils/urls-local-registry.js +24 -1
  83. package/lib/utils/worker-sidecars.js +28 -1
  84. package/lib/validation/external-system-auth-rules.js +45 -7
  85. package/package.json +2 -1
  86. package/scripts/test-dataplane-bootstrap.js +331 -0
  87. package/templates/applications/dataplane/env.template +22 -5
  88. package/templates/applications/dataplane/rbac.yaml +11 -0
  89. package/templates/applications/miso-controller/application.yaml +1 -1
  90. package/templates/applications/miso-controller/env.template +3 -0
  91. package/templates/infra/compose.yaml.hbs +13 -9
  92. package/templates/python/docker-compose.hbs +3 -0
  93. package/templates/typescript/docker-compose.hbs +3 -0
@@ -38,6 +38,10 @@ const ISOLATED_SUITE_ENTRIES = [
38
38
  'application-frontdoor-paths-contract',
39
39
  ['**/tests/lib/templates/application-frontdoor-paths.contract.test.js']
40
40
  ],
41
+ [
42
+ 'dataplane-env-template-contract',
43
+ ['**/tests/lib/templates/dataplane-env-template.contract.test.js']
44
+ ],
41
45
  ['database-secret-values', ['**/tests/lib/parameters/database-secret-values.test.js']],
42
46
  ['infra-parameter-validate', ['**/tests/lib/parameters/infra-parameter-validate.test.js']],
43
47
  [
@@ -56,6 +60,8 @@ const ISOLATED_SUITE_ENTRIES = [
56
60
  ['secrets-ensure-catalog-fallback', ['**/tests/lib/core/secrets-ensure-catalog-fallback.test.js']],
57
61
  ['secrets-ensure', ['**/tests/lib/core/secrets-ensure.test.js']],
58
62
  ['url-declarative-vdir-inactive-env', ['**/tests/lib/utils/url-declarative-vdir-inactive-env.test.js']],
63
+ ['url-declarative-resolve-load-doc', ['**/tests/lib/utils/url-declarative-resolve-load-doc.test.js']],
64
+ ['compose-celery-worker', ['**/tests/lib/utils/compose-celery-worker.test.js']],
59
65
  [
60
66
  'url-declarative-user-cfg-per-app-proxy',
61
67
  ['**/tests/lib/utils/url-declarative-user-cfg-per-app-proxy.test.js']
@@ -75,6 +81,7 @@ const ISOLATED_SUITE_ENTRIES = [
75
81
  ['infra-kv-discovery', ['**/tests/lib/parameters/infra-kv-discovery.test.js']],
76
82
  ['infra-env-defaults', ['**/tests/lib/utils/infra-env-defaults.test.js']],
77
83
  ['compose-traefik-template', ['**/tests/lib/infrastructure/compose-traefik-template.test.js']],
84
+ ['traefik-tls-dynamic', ['**/tests/lib/infrastructure/traefik-tls-dynamic.test.js']],
78
85
  ['generator-external-rbac', ['**/tests/lib/generator/generator-external-rbac.test.js']],
79
86
  ['helpers-ensure-admin-secrets', ['**/tests/lib/infrastructure/helpers-ensure-admin-secrets.test.js']],
80
87
  ['secrets-generator', ['**/tests/lib/utils/secrets-generator.test.js']],
package/jest.projects.js CHANGED
@@ -156,6 +156,14 @@ const defaultProject = {
156
156
  '\\\\tests\\\\lib\\\\utils\\\\url-declarative-vdir-inactive-env.test.js',
157
157
  'lib/utils/url-declarative-vdir-inactive-env.test.js',
158
158
  'url-declarative-vdir-inactive-env\\.test\\.js',
159
+ '/tests/lib/utils/url-declarative-resolve-load-doc.test.js',
160
+ '\\\\tests\\\\lib\\\\utils\\\\url-declarative-resolve-load-doc.test.js',
161
+ 'lib/utils/url-declarative-resolve-load-doc.test.js',
162
+ 'url-declarative-resolve-load-doc\\.test\\.js',
163
+ '/tests/lib/utils/compose-celery-worker.test.js',
164
+ '\\\\tests\\\\lib\\\\utils\\\\compose-celery-worker.test.js',
165
+ 'lib/utils/compose-celery-worker.test.js',
166
+ 'compose-celery-worker\\.test\\.js',
159
167
  '/tests/lib/utils/url-declarative-user-cfg-per-app-proxy.test.js',
160
168
  '\\\\tests\\\\lib\\\\utils\\\\url-declarative-user-cfg-per-app-proxy.test.js',
161
169
  'lib/utils/url-declarative-user-cfg-per-app-proxy.test.js',
@@ -200,6 +208,10 @@ const defaultProject = {
200
208
  '\\\\tests\\\\lib\\\\infrastructure\\\\compose-traefik-template.test.js',
201
209
  'lib/infrastructure/compose-traefik-template.test.js',
202
210
  'compose-traefik-template\\.test\\.js',
211
+ '/tests/lib/infrastructure/traefik-tls-dynamic.test.js',
212
+ '\\\\tests\\\\lib\\\\infrastructure\\\\traefik-tls-dynamic.test.js',
213
+ 'lib/infrastructure/traefik-tls-dynamic.test.js',
214
+ 'traefik-tls-dynamic\\.test\\.js',
203
215
  '/tests/lib/utils/paths-app-listing.test.js',
204
216
  '\\\\tests\\\\lib\\\\utils\\\\paths-app-listing.test.js',
205
217
  'lib/utils/paths-app-listing.test.js',
@@ -275,6 +287,10 @@ const defaultProject = {
275
287
  '\\\\tests\\\\lib\\\\templates\\\\application-frontdoor-paths.contract.test.js',
276
288
  'lib/templates/application-frontdoor-paths.contract.test.js',
277
289
  'application-frontdoor-paths\\.contract\\.test\\.js',
290
+ '/tests/lib/templates/dataplane-env-template.contract.test.js',
291
+ '\\\\tests\\\\lib\\\\templates\\\\dataplane-env-template.contract.test.js',
292
+ 'lib/templates/dataplane-env-template.contract.test.js',
293
+ 'dataplane-env-template\\.contract\\.test\\.js',
278
294
  '/tests/lib/core/admin-secrets.test.js',
279
295
  '\\\\tests\\\\lib\\\\core\\\\admin-secrets.test.js',
280
296
  'lib/core/admin-secrets.test.js',
@@ -142,6 +142,28 @@ async function registerApplication(controllerUrl, envKey, authConfig, registrati
142
142
  });
143
143
  }
144
144
 
145
+ /**
146
+ * Bootstrap application in an environment (install-time, ENCRYPTION_KEY header).
147
+ * POST /api/v1/environments/{envKey}/applications/bootstrap
148
+ * @async
149
+ * @function bootstrapApplication
150
+ * @param {string} controllerUrl - Controller base URL
151
+ * @param {string} envKey - Environment key
152
+ * @param {string} encryptionKey - Shared ENCRYPTION_KEY (sent as x-encryption-key; never log)
153
+ * @param {Object} registrationData - Same body as register
154
+ * @returns {Promise<Object>} Bootstrap response
155
+ * @throws {Error} If bootstrap fails
156
+ */
157
+ async function bootstrapApplication(controllerUrl, envKey, encryptionKey, registrationData) {
158
+ const client = new ApiClient(controllerUrl, {
159
+ type: 'bootstrap',
160
+ encryptionKey
161
+ });
162
+ return await client.post(`/api/v1/environments/${envKey}/applications/bootstrap`, {
163
+ body: registrationData
164
+ });
165
+ }
166
+
145
167
  /**
146
168
  * Rotate application secret
147
169
  * POST /api/v1/environments/{envKey}/applications/{appKey}/rotate-secret
@@ -187,6 +209,7 @@ module.exports = {
187
209
  updateApplication,
188
210
  deleteApplication,
189
211
  registerApplication,
212
+ bootstrapApplication,
190
213
  rotateApplicationSecret,
191
214
  getApplicationStatus
192
215
  };
@@ -198,7 +198,7 @@ async function listUsers(serverUrl, clientCertPem, serverCaPem) {
198
198
  * @requiresPermission {BuilderServer} Client certificate (X-Client-Cert)
199
199
  * @param {string} serverUrl - Builder Server base URL
200
200
  * @param {string} clientCertPem - PEM client certificate
201
- * @param {Object} body - CreateUserDto: developerId, name, email, optional groups (admin | secret-manager | developer | docker)
201
+ * @param {Object} body - CreateUserDto: developerId, name, email, optional groups (admin | secret-manager | secret-reader | developer | docker)
202
202
  * @param {string} [serverCaPem] - Dev root CA PEM
203
203
  * @returns {Promise<Object>} UserResponseDto
204
204
  */
package/lib/api/index.js CHANGED
@@ -17,8 +17,9 @@ class ApiClient {
17
17
  * App endpoints receive token-only auth (Bearer). x-client-id/x-client-secret are used only for the controller token endpoint.
18
18
  * @param {string} baseUrl - Base URL for the API (controller URL)
19
19
  * @param {Object} [authConfig] - Authentication configuration
20
- * @param {string} [authConfig.type] - Auth type ('bearer' | 'client-token' | 'client-credentials')
20
+ * @param {string} [authConfig.type] - Auth type ('bearer' | 'client-token' | 'client-credentials' | 'bootstrap')
21
21
  * @param {string} [authConfig.token] - Bearer token
22
+ * @param {string} [authConfig.encryptionKey] - Shared ENCRYPTION_KEY (bootstrap only; never log)
22
23
  * @param {string} [authConfig.clientId] - Client ID (client-credentials)
23
24
  * @param {string} [authConfig.clientSecret] - Client secret (client-credentials)
24
25
  */
@@ -79,7 +80,9 @@ class ApiClient {
79
80
  }
80
81
 
81
82
  // User token (bearer) → Authorization: Bearer; application token (client-token) → x-client-token
82
- if (this.authConfig.token) {
83
+ if (this.authConfig.type === 'bootstrap' && this.authConfig.encryptionKey) {
84
+ headers['x-encryption-key'] = String(this.authConfig.encryptionKey);
85
+ } else if (this.authConfig.token) {
83
86
  if (this.authConfig.type === 'client-token') {
84
87
  headers['x-client-token'] = this.authConfig.token;
85
88
  } else {
@@ -31,7 +31,8 @@
31
31
  * @property {string} aifabrix-env-config - Env config path
32
32
  * @property {string} remote-server - Builder-server base URL
33
33
  * @property {string} docker-endpoint - Docker API endpoint
34
- * @property {boolean} [docker-tls-skip-verify] - When true and ca.pem is absent, Docker uses DOCKER_TLS_VERIFY=0; if ca.pem exists, daemon is always verified
34
+ * @property {string} [developer-root-domain] - App hostname root (e.g. aifabrix.dev → dev01.aifabrix.dev)
35
+ * @property {boolean} [docker-tls-skip-verify] - When true, Docker uses DOCKER_TLS_VERIFY=0 even if ca.pem exists (client mTLS unchanged)
35
36
  * @property {string} sync-ssh-user - SSH user for Mutagen
36
37
  * @property {string} sync-ssh-host - SSH host for Mutagen
37
38
  */
@@ -45,7 +46,7 @@
45
46
  * @property {string} createdAt - ISO 8601
46
47
  * @property {boolean} certificateIssued - Whether cert was issued
47
48
  * @property {string} [certificateValidNotAfter] - Cert validity end (optional)
48
- * @property {string[]} groups - Access groups (admin, secret-manager, developer, docker)
49
+ * @property {string[]} groups - Access groups (admin, secret-manager, secret-reader, developer, docker)
49
50
  */
50
51
 
51
52
  /**
@@ -54,7 +55,7 @@
54
55
  * @property {string} developerId - Unique developer ID (numeric string)
55
56
  * @property {string} name - Display name
56
57
  * @property {string} email - Email
57
- * @property {string[]} [groups] - Default [developer]; tokens: admin, secret-manager, developer, docker
58
+ * @property {string[]} [groups] - Default [developer]; tokens: admin, secret-manager, secret-reader, developer, docker
58
59
  */
59
60
 
60
61
  /**
@@ -80,7 +80,8 @@ async function extractDeploymentConfig(options, _variables) {
80
80
  repositoryUrl: options.repositoryUrl,
81
81
  // Omit default here so deployer can resolve 2s vs 5s from controller deploymentType (GET /api/v1/health)
82
82
  pollInterval: Number.isFinite(pollIntervalExplicit) ? pollIntervalExplicit : undefined,
83
- pollMaxAttempts: options.pollMaxAttempts || 60
83
+ pollMaxAttempts: options.pollMaxAttempts || 60,
84
+ deploymentAuth: options.deploymentAuth
84
85
  };
85
86
  }
86
87
 
@@ -118,7 +119,7 @@ async function configureDeploymentEnvironment(_options, deploymentConfig) {
118
119
  * @returns {Promise<void>}
119
120
  * @throws {Error} If authentication fails
120
121
  */
121
- async function refreshDeploymentToken(appName, deploymentConfig) {
122
+ async function refreshDeploymentToken(appName, deploymentConfig, options = {}) {
122
123
  if (!deploymentConfig.controllerUrl) {
123
124
  throw new Error('Controller URL is required. Run "aifabrix login" to set the controller URL in config.yaml');
124
125
  }
@@ -127,7 +128,8 @@ async function refreshDeploymentToken(appName, deploymentConfig) {
127
128
  const authConfig = await getDeploymentAuth(
128
129
  deploymentConfig.controllerUrl,
129
130
  deploymentConfig.envKey,
130
- appName
131
+ appName,
132
+ { deploymentAuth: options.deploymentAuth || deploymentConfig.deploymentAuth }
131
133
  );
132
134
  if (!authConfig || !authConfig.controller) {
133
135
  throw new Error('Invalid authentication configuration: missing controller URL');
@@ -159,7 +161,7 @@ async function loadDeploymentConfig(appName, options) {
159
161
  const deploymentConfig = await extractDeploymentConfig(options, variables);
160
162
 
161
163
  await configureDeploymentEnvironment(options, deploymentConfig);
162
- await refreshDeploymentToken(appName, deploymentConfig);
164
+ await refreshDeploymentToken(appName, deploymentConfig, options);
163
165
 
164
166
  validateDeploymentConfig(deploymentConfig);
165
167
 
@@ -78,38 +78,40 @@ function buildRegistrationData(appConfig, options) {
78
78
  }
79
79
 
80
80
  /**
81
- * Save credentials to local secrets if localhost
81
+ * Save pipeline client credentials to ~/.aifabrix/secrets.local.yaml (always).
82
+ * env.template / in-memory resolve validation runs only for localhost controllers.
82
83
  * @async
83
84
  * @param {Object} responseData - Registration response data
84
85
  * @param {string} apiUrl - API URL
85
86
  */
86
87
  async function saveLocalCredentials(responseData, apiUrl) {
87
- if (!isLocalhost(apiUrl)) {
88
- return;
89
- }
90
-
91
88
  const registeredAppKey = responseData.application.key;
92
89
  const clientIdKey = `${registeredAppKey}-client-idKeyVault`;
93
90
  const clientSecretKey = `${registeredAppKey}-client-secretKeyVault`;
91
+ const localhostController = isLocalhost(apiUrl);
94
92
 
95
93
  try {
96
94
  await saveLocalSecret(clientIdKey, responseData.credentials.clientId);
97
95
  await saveLocalSecret(clientSecretKey, responseData.credentials.clientSecret);
98
96
 
99
- // Update env.template (kv:// refs only; no resolved values touch disk)
100
- await updateEnvTemplate(registeredAppKey, clientIdKey, clientSecretKey, apiUrl);
101
-
102
- // Resolve in-memory so any missing-secret / kv:// error surfaces here, but never
103
- // materialize <appPath>/.env or envOutputPath — that is only done by `aifabrix resolve`.
104
- try {
105
- await generateEnvFile(registeredAppKey, null, 'local', true, { noWrite: true });
106
- } catch (error) {
107
- logger.warn(chalk.yellow(`⚠ Could not validate env resolution: ${error.message}`));
97
+ if (localhostController) {
98
+ // Update env.template (kv:// refs only; no resolved values touch disk)
99
+ await updateEnvTemplate(registeredAppKey, clientIdKey, clientSecretKey, apiUrl);
100
+
101
+ // Resolve in-memory so any missing-secret / kv:// error surfaces here, but never
102
+ // materialize <appPath>/.env or envOutputPath — that is only done by `aifabrix resolve`.
103
+ try {
104
+ await generateEnvFile(registeredAppKey, null, 'local', true, { noWrite: true });
105
+ } catch (error) {
106
+ logger.warn(chalk.yellow(`⚠ Could not validate env resolution: ${error.message}`));
107
+ }
108
+
109
+ logger.log(formatSuccessParagraph('Credentials saved to ~/.aifabrix/secrets.local.yaml'));
110
+ logger.log(formatSuccessLine('env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL'));
111
+ logger.log(formatSuccessLine('Run "aifabrix resolve ' + registeredAppKey + '" to materialize an on-disk .env\n'));
112
+ } else {
113
+ logger.log(formatSuccessParagraph('Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
108
114
  }
109
-
110
- logger.log(formatSuccessParagraph('Credentials saved to ~/.aifabrix/secrets.local.yaml'));
111
- logger.log(formatSuccessLine('env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL'));
112
- logger.log(formatSuccessLine('Run "aifabrix resolve ' + registeredAppKey + '" to materialize an on-disk .env\n'));
113
115
  } catch (error) {
114
116
  logger.warn(chalk.yellow(`⚠ Could not save credentials locally: ${error.message}`));
115
117
  }
@@ -197,5 +199,5 @@ async function registerApplication(appKey, options = {}) {
197
199
  displayRegistrationResults(responseData, authConfig.apiUrl, environment, registrationData.displayName);
198
200
  }
199
201
 
200
- module.exports = { registerApplication, getEnvironmentPrefix };
202
+ module.exports = { registerApplication, getEnvironmentPrefix, buildRegistrationData, saveLocalCredentials, applyLocalhostPortAdjustment };
201
203
 
@@ -63,16 +63,34 @@ function validateEnvironment(environment) {
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * @param {number} code
68
+ * @param {string} message
69
+ * @param {{ exitOnFailure?: boolean }} [options]
70
+ * @throws {Error}
71
+ */
72
+ function failRotation(code, message, options = {}) {
73
+ if (options.exitOnFailure === false) {
74
+ throw new Error(message);
75
+ }
76
+ logger.error(formatBlockingError(message));
77
+ process.exit(code);
78
+ }
79
+
66
80
  /**
67
81
  * Resolve controller URL and environment from config; exit if controller is missing.
68
82
  * @async
83
+ * @param {{ exitOnFailure?: boolean }} [options]
69
84
  * @returns {Promise<{controllerUrl: string, environment: string}>}
70
85
  */
71
- async function resolveControllerAndEnvironment() {
86
+ async function resolveControllerAndEnvironment(options = {}) {
72
87
  const controllerUrl = await resolveControllerUrl();
73
88
  if (!controllerUrl) {
74
- logger.error(formatBlockingError('Controller URL is required. Run "aifabrix login" to set the controller URL in config.yaml'));
75
- process.exit(1);
89
+ failRotation(
90
+ 1,
91
+ 'Controller URL is required. Run "aifabrix login" to set the controller URL in config.yaml',
92
+ options
93
+ );
76
94
  }
77
95
  const environment = await resolveEnvironment();
78
96
  validateEnvironment(environment);
@@ -197,9 +215,10 @@ function displayRotationResults(appKey, environment, credentials, apiUrl, messag
197
215
  * Get device token from provided controller URL
198
216
  * @async
199
217
  * @param {string} controllerUrl - Controller URL
218
+ * @param {{ exitOnFailure?: boolean }} [options]
200
219
  * @returns {Promise<Object|null>} Object with token and controllerUrl, or null if failed
201
220
  */
202
- async function getTokenFromUrl(controllerUrl) {
221
+ async function getTokenFromUrl(controllerUrl, options = {}) {
203
222
  try {
204
223
  const normalizedUrl = normalizeControllerUrl(controllerUrl);
205
224
  const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
@@ -210,9 +229,7 @@ async function getTokenFromUrl(controllerUrl) {
210
229
  };
211
230
  }
212
231
  } catch (error) {
213
- logger.error(formatBlockingError(`Failed to authenticate with controller: ${controllerUrl}`));
214
- logger.error(chalk.gray(`Error: ${error.message}`));
215
- process.exit(1);
232
+ failRotation(1, `Failed to authenticate with controller: ${controllerUrl} (${error.message})`, options);
216
233
  }
217
234
  return null;
218
235
  }
@@ -222,9 +239,13 @@ async function getTokenFromUrl(controllerUrl) {
222
239
  * @param {string|null} token - Authentication token
223
240
  * @param {string|null} controllerUrl - Controller URL
224
241
  * @param {string} [providedUrl] - Original provided URL for error context
242
+ * @param {{ exitOnFailure?: boolean }} [options]
225
243
  */
226
- function validateAuthToken(token, controllerUrl, providedUrl) {
244
+ function validateAuthToken(token, controllerUrl, providedUrl, options = {}) {
227
245
  if (!token || !controllerUrl) {
246
+ if (options.exitOnFailure === false) {
247
+ throw new Error('No valid authentication found');
248
+ }
228
249
  const formattedError = formatAuthenticationError({
229
250
  controllerUrl: providedUrl || undefined,
230
251
  message: 'No valid authentication found'
@@ -239,16 +260,17 @@ function validateAuthToken(token, controllerUrl, providedUrl) {
239
260
  * @async
240
261
  * @param {string} [controllerUrl] - Optional controller URL
241
262
  * @param {Object} config - Configuration object
263
+ * @param {{ exitOnFailure?: boolean }} [options]
242
264
  * @returns {Promise<Object>} Object with token and actualControllerUrl
243
265
  * @throws {Error} If authentication fails
244
266
  */
245
- async function getRotationAuthToken(controllerUrl, config) {
267
+ async function getRotationAuthToken(controllerUrl, config, options = {}) {
246
268
  let token = null;
247
269
  let actualControllerUrl = null;
248
270
 
249
271
  // If controller URL provided, try to get device token
250
272
  if (controllerUrl) {
251
- const tokenResult = await getTokenFromUrl(controllerUrl);
273
+ const tokenResult = await getTokenFromUrl(controllerUrl, options);
252
274
  if (tokenResult) {
253
275
  token = tokenResult.token;
254
276
  actualControllerUrl = tokenResult.controllerUrl;
@@ -264,7 +286,7 @@ async function getRotationAuthToken(controllerUrl, config) {
264
286
  }
265
287
  }
266
288
 
267
- validateAuthToken(token, actualControllerUrl, controllerUrl);
289
+ validateAuthToken(token, actualControllerUrl, controllerUrl, options);
268
290
  return { token, actualControllerUrl };
269
291
  }
270
292
 
@@ -323,6 +345,9 @@ async function executeRotation(appKey, actualControllerUrl, environment, token,
323
345
 
324
346
  if (!response.success) {
325
347
  const formattedError = response.formattedError || formatApiError(response, actualControllerUrl);
348
+ if (options.exitOnFailure === false) {
349
+ throw new Error(formattedError || `Rotate secret failed for ${appKey}`);
350
+ }
326
351
  logger.error(formattedError);
327
352
  logger.error(chalk.gray(`\nController URL: ${actualControllerUrl}`));
328
353
  process.exit(1);
@@ -351,16 +376,14 @@ async function rotateSecret(appKey, options = {}) {
351
376
  const { ensureSecretsEncryptionKey } = require('../core/config');
352
377
  await ensureSecretsEncryptionKey();
353
378
 
354
- const { controllerUrl, environment } = await resolveControllerAndEnvironment();
379
+ const { controllerUrl, environment } = await resolveControllerAndEnvironment(options);
355
380
  const config = await getConfig();
356
- const { token, actualControllerUrl } = await getRotationAuthToken(controllerUrl, config);
381
+ const { token, actualControllerUrl } = await getRotationAuthToken(controllerUrl, config, options);
357
382
 
358
383
  try {
359
384
  await executeRotation(appKey, actualControllerUrl, environment, token, options);
360
385
  } catch (error) {
361
- logger.error(formatBlockingError(`Failed to rotate secret via controller: ${actualControllerUrl}`));
362
- logger.error(chalk.gray(`Error: ${error.message}`));
363
- process.exit(1);
386
+ failRotation(1, `Failed to rotate secret via controller: ${actualControllerUrl} (${error.message})`, options);
364
387
  }
365
388
  }
366
389
 
@@ -112,15 +112,6 @@ async function waitForHealthyAndCleanupEnvFiles(appName, port, appConfig, o) {
112
112
  logger.log(formatSuccessLine(`Container ${containerName} started`));
113
113
  await containerHelpers.logContainerStatus(containerName, debug);
114
114
 
115
- const healthCheckPath = appConfig?.healthCheck?.path || '/health';
116
- const healthUrl = (typeof healthCheck.computeHealthCheckUrl === 'function')
117
- ? await healthCheck.computeHealthCheckUrl(appName, port, appConfig, { runOptions: ro })
118
- : null;
119
- // Keep a stable fallback in the message if URL computation fails for any reason.
120
- const displayUrl = (healthUrl && typeof healthUrl === 'string')
121
- ? healthUrl
122
- : `http://localhost:${port}${healthCheckPath}`;
123
- logger.log(formatProgress(`Waiting for healthy: ${displayUrl}…`));
124
115
  await healthCheck.waitForHealthCheck(appName, 90, port, appConfig, debug, ro);
125
116
 
126
117
  for (const p of [runEnvPath, runEnvAdminPath]) {
@@ -290,8 +290,10 @@ function calculateComposePort(options, appConfig, developerId) {
290
290
  * @param {string} devDir - Dev directory path
291
291
  * @returns {Promise<string>} Path to compose file
292
292
  */
293
- async function generateComposeFile(appName, appConfig, composeOptions, devDir) {
294
- logger.log(formatProgress('Generating Docker Compose configuration…'));
293
+ async function generateComposeFile(appName, appConfig, composeOptions, devDir, logOpts = {}) {
294
+ if (!logOpts.suppressProgressLog) {
295
+ logger.log(formatProgress('Generating Docker Compose configuration…'));
296
+ }
295
297
  const composeContent = await composeGenerator.generateDockerCompose(appName, appConfig, composeOptions);
296
298
  runEnvCompose.assertNoPasswordLiteralsInCompose(composeContent);
297
299
  const tempComposePath = path.join(devDir, 'docker-compose.yaml');
@@ -352,7 +354,7 @@ async function prepareEnvironment(appName, appConfig, options) {
352
354
  );
353
355
 
354
356
  runEnvCompose.cleanApplicationsDir(developerId);
355
- logger.log(formatProgress('Building merged .env (admin + app secrets)…'));
357
+ logger.log(formatProgress('Preparing run environment (env + compose)…'));
356
358
  const { runEnvPath, runEnvAdminPath } = await runEnvCompose.buildMergedRunEnvAndWrite(
357
359
  appName,
358
360
  appConfig,
@@ -370,7 +372,9 @@ async function prepareEnvironment(appName, appConfig, options) {
370
372
  omitAppTraefikLabels: userCfg.traefik === false
371
373
  };
372
374
  composeOptions.port = calculateComposePort(composeOptions, appConfig, developerId);
373
- const composePath = await generateComposeFile(appName, appConfig, composeOptions, devDir);
375
+ const composePath = await generateComposeFile(appName, appConfig, composeOptions, devDir, {
376
+ suppressProgressLog: true
377
+ });
374
378
 
375
379
  await writeEnvOutputIfConfigured(appName, appConfig, runEnvPath, options);
376
380
 
@@ -9,16 +9,15 @@
9
9
  'use strict';
10
10
 
11
11
  const chalk = require('chalk');
12
- const { formatProgress } = require('../utils/cli-test-layout-chalk');
12
+ const { formatProgress, metadata } = require('../utils/cli-test-layout-chalk');
13
13
  const config = require('../core/config');
14
14
  const logger = require('../utils/logger');
15
15
  const mutagen = require('../utils/mutagen');
16
16
  const pathsUtil = require('../utils/paths');
17
17
  const {
18
18
  isReloadBindMountOnEngineHost,
19
- isLocalhostSyncSshHost
19
+ isHostOnThisMachine
20
20
  } = require('../utils/docker-reload-mount');
21
- const { sectionTitle, headerKeyValue, metadata } = require('../utils/cli-test-layout-chalk');
22
21
 
23
22
  /**
24
23
  * @typedef {Object} ReloadSyncSummaryBind
@@ -109,9 +108,11 @@ async function ensureReloadSync(appName, developerId, debug, codePath, remoteSyn
109
108
  return { transport: 'bind-mount', hostPath: codePath };
110
109
  }
111
110
  const syncSshHost = await config.getSyncSshHost();
112
- if (isLocalhostSyncSshHost(syncSshHost || '')) {
111
+ if (isHostOnThisMachine(syncSshHost || '')) {
113
112
  if (debug) {
114
- logger.log(chalk.gray('[DEBUG] sync-ssh-host is localhost; skipping Mutagen, using local path'));
113
+ logger.log(
114
+ chalk.gray('[DEBUG] sync-ssh-host is this machine; skipping Mutagen, using local path')
115
+ );
115
116
  }
116
117
  return { transport: 'bind-mount', hostPath: codePath };
117
118
  }
@@ -127,22 +128,19 @@ function logReloadDevSummary(reload, summary) {
127
128
  if (!reload || !summary) {
128
129
  return;
129
130
  }
130
- logger.log('');
131
- logger.log(sectionTitle('Reload (dev)'));
132
131
  if (summary.transport === 'bind-mount') {
133
- logger.log(headerKeyValue('Transport:', 'Direct bind mount on the Docker host (no Mutagen).'));
134
- logger.log(headerKeyValue('Host path → container:', `${summary.hostPath} → /app`));
135
- logger.log(metadata('Edits under the host path are visible inside the container immediately.'));
136
- logger.log('');
132
+ logger.log(
133
+ metadata(
134
+ 'Reload (dev) — Edits under the host path are visible inside the container immediately.'
135
+ )
136
+ );
137
137
  return;
138
138
  }
139
- logger.log(headerKeyValue('Transport:', 'Mutagen two-way sync (Docker engine on another host).'));
140
- logger.log(headerKeyValue('Session:', summary.sessionName));
141
- logger.log(headerKeyValue('Sync host:', summary.syncSshHost));
142
- logger.log(headerKeyValue('Local folder:', summary.localPath));
143
- logger.log(headerKeyValue('Remote mount (-v):', summary.remotePath));
144
- logger.log(metadata(`Mutagen endpoint: ${summary.sshUrl}`));
145
- logger.log('');
139
+ logger.log(
140
+ metadata(
141
+ `Reload (dev) — Mutagen sync (${summary.sessionName}); edits sync to ${summary.remotePath} before the container sees them.`
142
+ )
143
+ );
146
144
  }
147
145
 
148
146
  module.exports = { ensureReloadSync, logReloadDevSummary };
@@ -8,7 +8,10 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const { formatWarningLine } = require('../utils/cli-test-layout-chalk');
11
+ const {
12
+ formatProgress,
13
+ formatSuccessLine
14
+ } = require('../utils/cli-test-layout-chalk');
12
15
  const config = require('../core/config');
13
16
  const logger = require('../utils/logger');
14
17
  const containerHelpers = require('../utils/app-run-containers');
@@ -16,6 +19,34 @@ const helpers = require('./run-helpers');
16
19
  const { computeEffectiveEnvironmentScopedResources } = require('../utils/environment-scoped-resources');
17
20
  const { getCeleryCompanionAppKeys } = require('../utils/celery-companion-apps');
18
21
 
22
+ function shouldUseReplaceContainerSpinner() {
23
+ return Boolean(process && process.stdout && process.stdout.isTTY);
24
+ }
25
+
26
+ /**
27
+ * @param {string} progressText
28
+ * @param {string} successText
29
+ * @param {() => Promise<void>} work
30
+ * @returns {Promise<void>}
31
+ */
32
+ async function runContainerReplaceLine(progressText, successText, work) {
33
+ if (shouldUseReplaceContainerSpinner()) {
34
+ const ora = require('ora');
35
+ const spinner = ora({ text: progressText, spinner: 'dots' }).start();
36
+ try {
37
+ await work();
38
+ spinner.succeed(successText);
39
+ } catch (error) {
40
+ spinner.fail(progressText.replace(/…$/, '').trim());
41
+ throw error;
42
+ }
43
+ return;
44
+ }
45
+ logger.log(formatProgress(progressText));
46
+ await work();
47
+ logger.log(formatSuccessLine(successText));
48
+ }
49
+
19
50
  /**
20
51
  * @param {Object} appConfig
21
52
  * @param {Object} options
@@ -62,8 +93,14 @@ async function findRunningCompanionKeys(companionKeys, developerId, debug, scope
62
93
  async function stopCompanionContainers(companionKeys, developerId, debug, scopeOpts) {
63
94
  for (const companionKey of companionKeys) {
64
95
  const companionName = containerHelpers.getContainerName(companionKey, developerId, scopeOpts);
65
- logger.log(formatWarningLine(`Stopping companion container ${companionName}`));
66
- await helpers.stopAndRemoveContainer(companionKey, developerId, debug, scopeOpts);
96
+ await runContainerReplaceLine(
97
+ `Stopping companion container ${companionName}…`,
98
+ `Companion container ${companionName} stopped and removed`,
99
+ () =>
100
+ helpers.stopAndRemoveContainer(companionKey, developerId, debug, scopeOpts, {
101
+ suppressLog: true
102
+ })
103
+ );
67
104
  }
68
105
  }
69
106
 
@@ -101,8 +138,14 @@ async function checkAndStopContainer(appName, appConfig, options, debug) {
101
138
  return;
102
139
  }
103
140
  const containerName = containerHelpers.getContainerName(appName, developerId, scopeOpts);
104
- logger.log(formatWarningLine(`Container ${containerName} is already running`));
105
- await helpers.stopAndRemoveContainer(appName, developerId, debug, scopeOpts);
141
+ await runContainerReplaceLine(
142
+ `Replacing running container ${containerName}…`,
143
+ `Container ${containerName} stopped and removed`,
144
+ () =>
145
+ helpers.stopAndRemoveContainer(appName, developerId, debug, scopeOpts, {
146
+ suppressLog: true
147
+ })
148
+ );
106
149
  }
107
150
 
108
151
  module.exports = {
package/lib/app/run.js CHANGED
@@ -29,7 +29,7 @@ const { ensureReloadSync, logReloadDevSummary } = require('./run-reload-sync');
29
29
  const { logRestartDevMountSummary } = require('./restart-display');
30
30
  const { checkAndStopContainer } = require('./run-stop-containers');
31
31
  const { execWithDockerEnv } = require('../utils/docker-exec');
32
- const { isRunCliNoProxy } = require('../utils/run-cli-flags');
32
+ const { resolveRunProxyPersistEnabled, resolveRunEffectiveNoProxy } = require('../utils/run-cli-flags');
33
33
 
34
34
  /**
35
35
  * True if host is localhost or 127.0.0.1 (case-insensitive).
@@ -347,7 +347,10 @@ async function prepareAppRun(appName, options, debug) {
347
347
  }
348
348
  let userCfg = await config.getConfig();
349
349
  const appsDefaults = require('../utils/applications-config-defaults');
350
- await appsDefaults.persistApplicationRunProxyFlag(appName, !isRunCliNoProxy(options));
350
+ await appsDefaults.persistApplicationRunProxyFlag(
351
+ appName,
352
+ resolveRunProxyPersistEnabled(options, userCfg)
353
+ );
351
354
  if (envKey === 'dev') {
352
355
  await appsDefaults.persistApplicationReloadFlag(appName, options.reload === true);
353
356
  }
@@ -356,7 +359,7 @@ async function prepareAppRun(appName, options, debug) {
356
359
  const mergedOpts = {
357
360
  ...options,
358
361
  reload: options.reload === true,
359
- noProxy: isRunCliNoProxy(options) || !savedProxyHint
362
+ noProxy: resolveRunEffectiveNoProxy(options, userCfg, savedProxyHint)
360
363
  };
361
364
  logger.log('');
362
365
  logger.log(sectionTitle('Run'));
package/lib/app/show.js CHANGED
@@ -45,11 +45,11 @@ const {
45
45
  * @param {Object} userCfg
46
46
  */
47
47
  function attachRunDefaultsFromUserConfig(summary, appKey, userCfg) {
48
- const { isApplicationsReloadDefaultOn, getApplicationsRunProxyHint } = require('../utils/applications-config-defaults');
48
+ const { isApplicationsReloadDefaultOn, getEffectiveApplicationsRunProxyHint } = require('../utils/applications-config-defaults');
49
49
  if (isApplicationsReloadDefaultOn(userCfg, appKey)) {
50
50
  summary.runReloadDefault = true;
51
51
  }
52
- summary.runProxyDefault = getApplicationsRunProxyHint(userCfg, appKey);
52
+ summary.runProxyDefault = getEffectiveApplicationsRunProxyHint(userCfg, appKey);
53
53
  }
54
54
 
55
55
  /**