@aifabrix/builder 2.38.0 → 2.39.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 (48) hide show
  1. package/.cursor/rules/project-rules.mdc +3 -0
  2. package/integration/hubspot/hubspot-deploy.json +0 -3
  3. package/integration/hubspot/hubspot-system.json +0 -3
  4. package/lib/api/applications.api.js +8 -2
  5. package/lib/api/auth.api.js +14 -0
  6. package/lib/api/credentials.api.js +1 -1
  7. package/lib/api/datasources-core.api.js +16 -1
  8. package/lib/api/datasources-extended.api.js +18 -1
  9. package/lib/api/deployments.api.js +6 -1
  10. package/lib/api/environments.api.js +11 -0
  11. package/lib/api/external-systems.api.js +16 -1
  12. package/lib/api/pipeline.api.js +12 -4
  13. package/lib/api/service-users.api.js +41 -0
  14. package/lib/api/types/service-users.types.js +24 -0
  15. package/lib/api/wizard.api.js +19 -0
  16. package/lib/app/deploy.js +86 -21
  17. package/lib/app/rotate-secret.js +3 -1
  18. package/lib/app/run-helpers.js +7 -2
  19. package/lib/app/show-display.js +30 -11
  20. package/lib/app/show.js +34 -8
  21. package/lib/cli/index.js +2 -0
  22. package/lib/cli/setup-app.js +8 -0
  23. package/lib/cli/setup-infra.js +3 -3
  24. package/lib/cli/setup-service-user.js +52 -0
  25. package/lib/commands/app.js +2 -1
  26. package/lib/commands/service-user.js +193 -0
  27. package/lib/commands/up-common.js +74 -5
  28. package/lib/commands/up-dataplane.js +13 -7
  29. package/lib/commands/up-miso.js +17 -24
  30. package/lib/core/templates.js +0 -1
  31. package/lib/external-system/deploy.js +79 -15
  32. package/lib/generator/builders.js +0 -24
  33. package/lib/schema/application-schema.json +0 -12
  34. package/lib/schema/external-system.schema.json +0 -16
  35. package/lib/utils/app-register-config.js +10 -12
  36. package/lib/utils/deployment-errors.js +10 -0
  37. package/lib/utils/environment-checker.js +25 -6
  38. package/lib/utils/variable-transformer.js +6 -14
  39. package/package.json +1 -1
  40. package/templates/applications/dataplane/README.md +23 -7
  41. package/templates/applications/dataplane/env.template +31 -2
  42. package/templates/applications/dataplane/rbac.yaml +1 -1
  43. package/templates/applications/dataplane/variables.yaml +2 -1
  44. package/templates/applications/keycloak/env.template +6 -3
  45. package/templates/applications/keycloak/variables.yaml +1 -0
  46. package/templates/applications/miso-controller/env.template +22 -15
  47. package/templates/applications/miso-controller/rbac.yaml +15 -0
  48. package/templates/applications/miso-controller/variables.yaml +24 -23
@@ -9,6 +9,7 @@ const { ApiClient } = require('./index');
9
9
  /**
10
10
  * Validate deployment configuration
11
11
  * POST /api/v1/pipeline/{envKey}/validate
12
+ * @requiresPermission {Controller} applications:deploy
12
13
  * @async
13
14
  * @function validatePipeline
14
15
  * @param {string} controllerUrl - Controller base URL
@@ -31,6 +32,7 @@ async function validatePipeline(controllerUrl, envKey, authConfig, validationDat
31
32
  /**
32
33
  * Deploy application using validateToken
33
34
  * POST /api/v1/pipeline/{envKey}/deploy
35
+ * @requiresPermission {Controller} applications:deploy
34
36
  * @async
35
37
  * @function deployPipeline
36
38
  * @param {string} controllerUrl - Controller base URL
@@ -52,6 +54,7 @@ async function deployPipeline(controllerUrl, envKey, authConfig, deployData) {
52
54
  /**
53
55
  * Get deployment status for CI/CD
54
56
  * GET /api/v1/pipeline/{envKey}/deployments/{deploymentId}
57
+ * @requiresPermission {Controller} applications:deploy
55
58
  * @async
56
59
  * @function getPipelineDeployment
57
60
  * @param {string} controllerUrl - Controller base URL
@@ -69,6 +72,7 @@ async function getPipelineDeployment(controllerUrl, envKey, deploymentId, authCo
69
72
  /**
70
73
  * Pipeline health check
71
74
  * GET /api/v1/pipeline/{envKey}/health
75
+ * @requiresPermission {Controller} Public (no auth required)
72
76
  * @async
73
77
  * @function getPipelineHealth
74
78
  * @param {string} controllerUrl - Controller base URL
@@ -87,7 +91,7 @@ async function getPipelineHealth(controllerUrl, envKey) {
87
91
  * Request body: external system JSON (external-system.schema.json). Optional field in body:
88
92
  * generateMcpContract (boolean, default true). Optional: generateOpenApiContract (boolean).
89
93
  * Do not use query parameters for MCP; use the field in the body only.
90
- *
94
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
91
95
  * @async
92
96
  * @function publishSystemViaPipeline
93
97
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -107,7 +111,7 @@ async function publishSystemViaPipeline(dataplaneUrl, authConfig, systemConfig)
107
111
  * Publish datasource via dataplane pipeline endpoint
108
112
  * POST /api/v1/pipeline/{systemKey}/publish
109
113
  * No generateMcpContract for this endpoint; dataplane always uses default (MCP generated).
110
- *
114
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
111
115
  * @async
112
116
  * @function publishDatasourceViaPipeline
113
117
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -127,6 +131,7 @@ async function publishDatasourceViaPipeline(dataplaneUrl, systemKey, authConfig,
127
131
  /**
128
132
  * Test datasource via dataplane pipeline endpoint
129
133
  * POST /api/v1/pipeline/{systemKey}/{datasourceKey}/test
134
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
130
135
  * @async
131
136
  * @function testDatasourceViaPipeline
132
137
  * @param {Object} params - Function parameters
@@ -156,6 +161,7 @@ async function testDatasourceViaPipeline({ dataplaneUrl, systemKey, datasourceKe
156
161
  /**
157
162
  * Deploy external system via dataplane pipeline endpoint
158
163
  * POST /api/v1/pipeline/deploy
164
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
159
165
  * @async
160
166
  * @function deployExternalSystemViaPipeline
161
167
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -174,6 +180,7 @@ async function deployExternalSystemViaPipeline(dataplaneUrl, authConfig, systemC
174
180
  /**
175
181
  * Deploy datasource via dataplane pipeline endpoint
176
182
  * POST /api/v1/pipeline/{systemKey}/deploy
183
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
177
184
  * @async
178
185
  * @function deployDatasourceViaPipeline
179
186
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -196,7 +203,7 @@ async function deployDatasourceViaPipeline(dataplaneUrl, systemKey, authConfig,
196
203
  * Body: { version, application, dataSources }. Include application.generateMcpContract
197
204
  * and/or application.generateOpenApiContract to control contract generation when
198
205
  * publishing this upload (publish reads from stored config; no query param on publish).
199
- *
206
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
200
207
  * @async
201
208
  * @function uploadApplicationViaPipeline
202
209
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -215,6 +222,7 @@ async function uploadApplicationViaPipeline(dataplaneUrl, authConfig, applicatio
215
222
  /**
216
223
  * Validate upload via dataplane pipeline endpoint
217
224
  * POST /api/v1/pipeline/upload/{uploadId}/validate
225
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
218
226
  * @async
219
227
  * @function validateUploadViaPipeline
220
228
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -235,7 +243,7 @@ async function validateUploadViaPipeline(dataplaneUrl, uploadId, authConfig) {
235
243
  * that was uploaded (application.generateMcpContract, application.generateOpenApiContract).
236
244
  * To control MCP/OpenAPI, include those fields in the application object when calling
237
245
  * uploadApplicationViaPipeline.
238
- *
246
+ * @requiresPermission {Dataplane} Authenticated (oauth2: [])
239
247
  * @async
240
248
  * @function publishUploadViaPipeline
241
249
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @fileoverview Service users API functions (create service user with one-time secret)
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const { ApiClient } = require('./index');
8
+
9
+ /**
10
+ * Create a service user (username, email, redirectUris, groupIds); return one-time secret
11
+ * POST /api/v1/service-users
12
+ * @requiresPermission {Controller} service-user:create
13
+ * @async
14
+ * @function createServiceUser
15
+ * @param {string} controllerUrl - Controller base URL
16
+ * @param {Object} authConfig - Authentication configuration (bearer or client-credentials)
17
+ * @param {Object} body - Request body
18
+ * @param {string} body.username - Username (required)
19
+ * @param {string} body.email - Email (required)
20
+ * @param {string[]} body.redirectUris - Redirect URIs for OAuth2 (required, min 1)
21
+ * @param {string[]} body.groupNames - Group names (required, e.g. AI-Fabrix-Developers)
22
+ * @param {string} [body.description] - Optional description
23
+ * @returns {Promise<Object>} Response with clientId and one-time clientSecret
24
+ * @throws {Error} If request fails (400/401/403 or network)
25
+ */
26
+ async function createServiceUser(controllerUrl, authConfig, body) {
27
+ const client = new ApiClient(controllerUrl, authConfig);
28
+ return await client.post('/api/v1/service-users', {
29
+ body: {
30
+ username: body.username,
31
+ email: body.email,
32
+ redirectUris: body.redirectUris,
33
+ groupNames: body.groupNames,
34
+ description: body.description
35
+ }
36
+ });
37
+ }
38
+
39
+ module.exports = {
40
+ createServiceUser
41
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @fileoverview Service users API type definitions
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ /**
8
+ * Service user create request body (builder sends this to controller)
9
+ * @typedef {Object} ServiceUserCreateRequest
10
+ * @property {string} username - Username (1–100 chars, e.g. api-client-001)
11
+ * @property {string} email - Email address
12
+ * @property {string[]} redirectUris - Allowed redirect URIs for OAuth2 (min 1)
13
+ * @property {string[]} groupNames - Group names to assign (e.g. AI-Fabrix-Developers)
14
+ * @property {string} [description] - Optional description (max 500 chars)
15
+ */
16
+
17
+ /**
18
+ * Service user create response (clientSecret is one-time-only; store at creation time)
19
+ * @typedef {Object} ServiceUserCreateResponse
20
+ * @property {string} clientId - Stable identifier for the service user
21
+ * @property {string} clientSecret - One-time-only secret; not returned by any other endpoint
22
+ * @property {boolean} [success] - Optional wrapper flag
23
+ * @property {string} [createdAt] - Optional creation timestamp (ISO 8601)
24
+ */
@@ -10,6 +10,7 @@ const { uploadFile } = require('../utils/file-upload');
10
10
  /**
11
11
  * Create wizard session
12
12
  * POST /api/v1/wizard/sessions
13
+ * @requiresPermission {Dataplane} external-system:create
13
14
  * @async
14
15
  * @function createWizardSession
15
16
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -33,6 +34,7 @@ async function createWizardSession(dataplaneUrl, authConfig, mode, systemIdOrKey
33
34
  /**
34
35
  * Get wizard session
35
36
  * GET /api/v1/wizard/sessions/{sessionId}
37
+ * @requiresPermission {Dataplane} external-system:read or external-system:create
36
38
  * @async
37
39
  * @function getWizardSession
38
40
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -49,6 +51,7 @@ async function getWizardSession(dataplaneUrl, sessionId, authConfig) {
49
51
  /**
50
52
  * Update wizard session
51
53
  * PUT /api/v1/wizard/sessions/{sessionId}
54
+ * @requiresPermission {Dataplane} external-system:create
52
55
  * @async
53
56
  * @function updateWizardSession
54
57
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -80,6 +83,7 @@ async function updateWizardSession(dataplaneUrl, sessionId, authConfig, updateDa
80
83
  /**
81
84
  * Delete wizard session
82
85
  * DELETE /api/v1/wizard/sessions/{sessionId}
86
+ * @requiresPermission {Dataplane} external-system:create
83
87
  * @async
84
88
  * @function deleteWizardSession
85
89
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -96,6 +100,7 @@ async function deleteWizardSession(dataplaneUrl, sessionId, authConfig) {
96
100
  /**
97
101
  * Get wizard session progress
98
102
  * GET /api/v1/wizard/sessions/{sessionId}/progress
103
+ * @requiresPermission {Dataplane} external-system:create
99
104
  * @async
100
105
  * @function getWizardProgress
101
106
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -112,6 +117,7 @@ async function getWizardProgress(dataplaneUrl, sessionId, authConfig) {
112
117
  /**
113
118
  * Parse OpenAPI file or URL
114
119
  * POST /api/v1/wizard/parse-openapi
120
+ * @requiresPermission {Dataplane} external-system:create
115
121
  * @async
116
122
  * @function parseOpenApi
117
123
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -133,6 +139,7 @@ async function parseOpenApi(dataplaneUrl, authConfig, openApiFilePathOrUrl, isUr
133
139
  /**
134
140
  * Credential selection for wizard
135
141
  * POST /api/v1/wizard/credential-selection
142
+ * @requiresPermission {Dataplane} external-system:create
136
143
  * @async
137
144
  * @function credentialSelection
138
145
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -154,6 +161,7 @@ async function credentialSelection(dataplaneUrl, authConfig, selectionData) {
154
161
  /**
155
162
  * Detect API type from OpenAPI spec
156
163
  * POST /api/v1/wizard/detect-type
164
+ * @requiresPermission {Dataplane} external-system:create
157
165
  * @async
158
166
  * @function detectType
159
167
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -172,6 +180,7 @@ async function detectType(dataplaneUrl, authConfig, openapiSpec) {
172
180
  /**
173
181
  * Generate configuration via AI
174
182
  * POST /api/v1/wizard/generate-config
183
+ * @requiresPermission {Dataplane} external-system:create
175
184
  * @async
176
185
  * @function generateConfig
177
186
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -202,6 +211,7 @@ async function generateConfig(dataplaneUrl, authConfig, config) {
202
211
  /**
203
212
  * Generate configuration via AI (streaming)
204
213
  * POST /api/v1/wizard/generate-config-stream
214
+ * @requiresPermission {Dataplane} external-system:create
205
215
  * @async
206
216
  * @function generateConfigStream
207
217
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -220,6 +230,7 @@ async function generateConfigStream(dataplaneUrl, authConfig, config) {
220
230
  /**
221
231
  * Validate wizard configuration
222
232
  * POST /api/v1/wizard/validate
233
+ * @requiresPermission {Dataplane} external-system:create
223
234
  * @async
224
235
  * @function validateWizardConfig
225
236
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -242,6 +253,7 @@ async function validateWizardConfig(dataplaneUrl, authConfig, systemConfig, data
242
253
  /**
243
254
  * Validate all completed wizard steps
244
255
  * GET /api/v1/wizard/sessions/{sessionId}/validate
256
+ * @requiresPermission {Dataplane} external-system:create
245
257
  * @async
246
258
  * @function validateAllSteps
247
259
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -258,6 +270,7 @@ async function validateAllSteps(dataplaneUrl, sessionId, authConfig) {
258
270
  /**
259
271
  * Validate specific wizard step
260
272
  * POST /api/v1/wizard/sessions/{sessionId}/validate-step
273
+ * @requiresPermission {Dataplane} external-system:create
261
274
  * @async
262
275
  * @function validateStep
263
276
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -275,6 +288,7 @@ async function validateStep(dataplaneUrl, sessionId, authConfig, stepNumber) {
275
288
  /**
276
289
  * Get configuration preview with summaries
277
290
  * GET /api/v1/wizard/preview/{sessionId}
291
+ * @requiresPermission {Dataplane} external-system:create
278
292
  * @async
279
293
  * @function getPreview
280
294
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -291,6 +305,7 @@ async function getPreview(dataplaneUrl, sessionId, authConfig) {
291
305
  /**
292
306
  * Test MCP server connection
293
307
  * POST /api/v1/wizard/test-mcp-connection
308
+ * @requiresPermission {Dataplane} external-system:create
294
309
  * @async
295
310
  * @function testMcpConnection
296
311
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -313,6 +328,7 @@ async function testMcpConnection(dataplaneUrl, authConfig, serverUrl, token) {
313
328
  /**
314
329
  * Get deployment documentation for a system (from dataplane DB only)
315
330
  * GET /api/v1/wizard/deployment-docs/{systemKey}
331
+ * @requiresPermission {Dataplane} external-system:read
316
332
  * @async
317
333
  * @function getDeploymentDocs
318
334
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -330,6 +346,7 @@ async function getDeploymentDocs(dataplaneUrl, authConfig, systemKey) {
330
346
  * Generate deployment documentation with variables.yaml and deploy JSON for better quality
331
347
  * POST /api/v1/wizard/deployment-docs/{systemKey}
332
348
  * Sends deployJson and variablesYaml in the request body so the dataplane can align README with the integration folder.
349
+ * @requiresPermission {Dataplane} external-system:update
333
350
  * @async
334
351
  * @function postDeploymentDocs
335
352
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -353,6 +370,7 @@ async function postDeploymentDocs(dataplaneUrl, authConfig, systemKey, body = nu
353
370
  * GET /api/v1/wizard/platforms
354
371
  * Expected response: { platforms: [ { key: string, displayName?: string } ] } or equivalent.
355
372
  * On 404 or error, returns empty array (caller should hide "Known platform" choice).
373
+ * @requiresPermission {Dataplane} external-system:read or unauthenticated
356
374
  * @async
357
375
  * @function getWizardPlatforms
358
376
  * @param {string} dataplaneUrl - Dataplane base URL
@@ -373,6 +391,7 @@ async function getWizardPlatforms(dataplaneUrl, authConfig) {
373
391
  /**
374
392
  * List credentials for wizard selection (Step 3)
375
393
  * GET /api/v1/wizard/credentials
394
+ * @requiresPermission {Dataplane} credential:read or external-system:create
376
395
  * @async
377
396
  * @function listWizardCredentials
378
397
  * @param {string} dataplaneUrl - Dataplane base URL
package/lib/app/deploy.js CHANGED
@@ -17,6 +17,7 @@ const pushUtils = require('../deployment/push');
17
17
  const logger = require('../utils/logger');
18
18
  const { detectAppType, getBuilderPath, getIntegrationPath } = require('../utils/paths');
19
19
  const { checkApplicationExists } = require('../utils/app-existence');
20
+ const { getApplicationStatus } = require('../api/applications.api');
20
21
  const { loadDeploymentConfig } = require('./deploy-config');
21
22
 
22
23
  /**
@@ -218,6 +219,26 @@ function displayDeploymentResults(result) {
218
219
  }
219
220
  }
220
221
 
222
+ /**
223
+ * Fetches app URL from controller application status and displays it.
224
+ * On API failure or missing URL, shows controllerUrl so the user always sees where the app is.
225
+ * @param {string} controllerUrl - Controller base URL (used as fallback)
226
+ * @param {string} envKey - Environment key
227
+ * @param {string} appKey - Application key (manifest.key)
228
+ * @param {Object} authConfig - Auth used for deployment (same as for status)
229
+ */
230
+ async function displayAppUrlFromController(controllerUrl, envKey, appKey, authConfig) {
231
+ let url = null;
232
+ try {
233
+ const res = await getApplicationStatus(controllerUrl, envKey, appKey, authConfig);
234
+ const body = res?.data;
235
+ url = (body && (body.url || body.data?.url)) || res?.url || null;
236
+ } catch (_) {
237
+ // Use controllerUrl fallback below
238
+ }
239
+ logger.log(chalk.green(` āœ“ App running at ${url || controllerUrl}`));
240
+ }
241
+
221
242
  /**
222
243
  * Check if app is external and handle external deployment.
223
244
  * When options.type === 'external', forces deployment from integration/<app> (no app register needed).
@@ -272,6 +293,59 @@ function validateImageIsPullable(imageRef, appName) {
272
293
  }
273
294
  }
274
295
 
296
+ /**
297
+ * Throws an error when deployment status is failed or cancelled
298
+ * @param {string} status - Status value
299
+ * @param {Object} statusObj - Full status object from result
300
+ * @throws {Error}
301
+ */
302
+ function throwIfDeploymentFailed(status, statusObj) {
303
+ if (status !== 'failed' && status !== 'cancelled') return;
304
+ const msg =
305
+ statusObj.message ||
306
+ statusObj.error ||
307
+ (status === 'cancelled' ? 'Deployment cancelled' : 'Deployment failed');
308
+ const err = new Error(`Deployment ${status}: ${msg}`);
309
+ err.formatted = `Deployment ${status}.\n\n${msg}`;
310
+ err.deploymentStatus = statusObj;
311
+ throw err;
312
+ }
313
+
314
+ /**
315
+ * Enhances 401 error with rotate-secret hint when app exists
316
+ * @param {Error} error - Caught error
317
+ * @param {boolean} appExists - Whether app exists in controller
318
+ * @param {string} appName - Application name
319
+ * @param {string} envKey - Environment key
320
+ * @throws {Error} Enhanced or original error
321
+ */
322
+ function enhanceAuthErrorIfNeeded(error, appExists, appName, envKey) {
323
+ if (!appExists || error.status !== 401 || error.message.includes('rotate-secret')) {
324
+ throw error;
325
+ }
326
+ const enhancedError = new Error(
327
+ `${error.message}\n\nšŸ’” The application '${appName}' exists in environment '${envKey}'. ` +
328
+ `To fix invalid credentials, rotate the application secret:\n aifabrix app rotate-secret ${appName}`
329
+ );
330
+ enhancedError.status = 401;
331
+ enhancedError.formatted = error.formatted || enhancedError.message;
332
+ throw enhancedError;
333
+ }
334
+
335
+ /**
336
+ * Apply image/registry overrides from options to manifest
337
+ * @param {Object} manifest - Deployment manifest
338
+ * @param {Object} options - Deployment options
339
+ */
340
+ function applyManifestOverrides(manifest, options) {
341
+ if (options.imageOverride || options.image) {
342
+ manifest.image = options.imageOverride || options.image;
343
+ }
344
+ if (options.registryMode) {
345
+ manifest.registryMode = options.registryMode;
346
+ }
347
+ }
348
+
275
349
  /**
276
350
  * Execute standard application deployment flow
277
351
  * @async
@@ -283,38 +357,29 @@ function validateImageIsPullable(imageRef, appName) {
283
357
  async function executeStandardDeployment(appName, options) {
284
358
  const config = await loadDeploymentConfig(appName, options);
285
359
  const controllerUrl = config.controllerUrl || 'unknown';
286
-
287
- // Check if application exists before deployment
288
360
  const appExists = await checkApplicationExists(appName, controllerUrl, config.envKey, config.auth);
289
361
 
290
362
  const { manifest, manifestPath } = await generateAndValidateManifest(appName);
291
- if (options.imageOverride || options.image) {
292
- manifest.image = options.imageOverride || options.image;
293
- }
294
- if (options.registryMode) {
295
- manifest.registryMode = options.registryMode;
296
- }
297
-
363
+ applyManifestOverrides(manifest, options);
298
364
  validateImageIsPullable(manifest.image, appName);
299
-
300
365
  displayDeploymentInfo(manifest, manifestPath);
301
366
 
302
367
  try {
303
368
  const result = await executeDeployment(manifest, config);
304
369
  displayDeploymentResults(result);
305
- return { result, controllerUrl, appExists };
306
- } catch (error) {
307
- // Enhance error if app exists and credentials are invalid
308
- if (appExists && error.status === 401 && !error.message.includes('rotate-secret')) {
309
- const enhancedError = new Error(
310
- `${error.message}\n\nšŸ’” The application '${appName}' exists in environment '${config.envKey}'. ` +
311
- `To fix invalid credentials, rotate the application secret:\n aifabrix app rotate-secret ${appName}`
370
+ const status = result.status?.status;
371
+ throwIfDeploymentFailed(status, result.status || {});
372
+ if (status === 'completed') {
373
+ await displayAppUrlFromController(
374
+ config.controllerUrl,
375
+ config.envKey,
376
+ manifest.key,
377
+ config.auth
312
378
  );
313
- enhancedError.status = 401;
314
- enhancedError.formatted = error.formatted || enhancedError.message;
315
- throw enhancedError;
316
379
  }
317
- throw error;
380
+ return { result, controllerUrl, appExists };
381
+ } catch (error) {
382
+ enhanceAuthErrorIfNeeded(error, appExists, appName, config.envKey);
318
383
  }
319
384
  }
320
385
 
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  const chalk = require('chalk');
12
- const { getConfig, normalizeControllerUrl, resolveEnvironment } = require('../core/config');
12
+ const { getConfig, normalizeControllerUrl, resolveEnvironment, clearClientToken } = require('../core/config');
13
13
  const { resolveControllerUrl } = require('../utils/controller-url');
14
14
  const { getOrRefreshDeviceToken } = require('../utils/token-manager');
15
15
  const { rotateApplicationSecret } = require('../api/applications.api');
@@ -324,6 +324,8 @@ async function executeRotation(appKey, actualControllerUrl, environment, token)
324
324
  const { credentials, message } = validateResponse(response);
325
325
  await saveCredentialsLocally(appKey, credentials, actualControllerUrl);
326
326
  displayRotationResults(appKey, environment, credentials, actualControllerUrl, message);
327
+ // Clear cached client token so next deploy uses new credentials (avoids "Invalid or expired credentials" when running deploy/up-dataplane immediately after rotate)
328
+ await clearClientToken(environment, appKey);
327
329
  }
328
330
 
329
331
  /**
@@ -31,6 +31,9 @@ const { resolveVersionForApp } = require('../utils/image-version');
31
31
 
32
32
  const execAsync = promisify(exec);
33
33
 
34
+ /** Template apps (keycloak, miso-controller, dataplane) - never update variables.yaml when running */
35
+ const TEMPLATE_APP_KEYS = ['keycloak', 'miso-controller', 'dataplane'];
36
+
34
37
  // Re-export container helper functions
35
38
  const checkImageExists = containerHelpers.checkImageExists;
36
39
  const checkContainerRunning = containerHelpers.checkContainerRunning;
@@ -140,15 +143,17 @@ async function validateAppConfiguration(appName) {
140
143
  }
141
144
 
142
145
  /**
143
- * Resolves version from image and updates builder/variables.yaml when running
146
+ * Resolves version from image and updates builder/variables.yaml when running.
147
+ * Template apps (keycloak, miso-controller, dataplane) are never updated - variables.yaml stays pristine.
144
148
  * @async
145
149
  * @param {string} appName - Application name
146
150
  * @param {Object} appConfig - Application configuration
147
151
  * @param {boolean} debug - Enable debug logging
148
152
  */
149
153
  async function resolveAndUpdateVersion(appName, appConfig, debug) {
154
+ const isTemplateApp = TEMPLATE_APP_KEYS.includes(appName);
150
155
  const resolved = await resolveVersionForApp(appName, appConfig, {
151
- updateBuilder: true,
156
+ updateBuilder: !isTemplateApp,
152
157
  builderPath: pathsUtil.getBuilderPath(appName)
153
158
  });
154
159
  if (resolved.fromImage && resolved.updated && debug) {
@@ -31,14 +31,21 @@ function logApplicationRequired(a) {
31
31
  logger.log(` Port: ${port}`);
32
32
  }
33
33
 
34
+ const APPLICATION_OPTIONAL_FIELDS = [
35
+ { key: 'deploymentKey', label: 'Deployment' },
36
+ { key: 'image', label: 'Image' },
37
+ { key: 'registryMode', label: 'Registry' },
38
+ { key: 'healthCheck', label: 'Health' },
39
+ { key: 'build', label: 'Build' },
40
+ { key: 'status', label: 'Status' },
41
+ { key: 'url', label: 'URL' },
42
+ { key: 'internalUrl', label: 'Internal URL' }
43
+ ];
44
+
34
45
  function logApplicationOptional(a) {
35
- if (a.deploymentKey !== undefined) logger.log(` Deployment: ${a.deploymentKey ?? '—'}`);
36
- if (a.image !== undefined) logger.log(` Image: ${a.image ?? '—'}`);
37
- if (a.registryMode !== undefined) logger.log(` Registry: ${a.registryMode ?? '—'}`);
38
- if (a.healthCheck !== undefined) logger.log(` Health: ${a.healthCheck ?? '—'}`);
39
- if (a.build !== undefined) logger.log(` Build: ${a.build ?? '—'}`);
40
- if (a.status !== undefined) logger.log(` Status: ${a.status ?? '—'}`);
41
- if (a.url !== undefined) logger.log(` URL: ${a.url ?? '—'}`);
46
+ APPLICATION_OPTIONAL_FIELDS.forEach(({ key, label }) => {
47
+ if (a[key] !== undefined) logger.log(` ${(label + ':').padEnd(16)} ${a[key] ?? '—'}`);
48
+ });
42
49
  }
43
50
 
44
51
  function logApplicationFields(a) {
@@ -75,10 +82,15 @@ function logRolesSection(roles) {
75
82
  });
76
83
  }
77
84
 
78
- function logPermissionsSection(permissions) {
79
- if (permissions.length === 0) return;
85
+ function logPermissionsSection(permissions, opts = {}) {
86
+ const showWhenEmpty = opts.showWhenEmpty || false;
87
+ if (permissions.length === 0 && !showWhenEmpty) return;
80
88
  logger.log('');
81
89
  logger.log('šŸ›”ļø Permissions');
90
+ if (permissions.length === 0) {
91
+ logger.log(' (none)');
92
+ return;
93
+ }
82
94
  permissions.forEach((p) => {
83
95
  const name = p.name ?? '—';
84
96
  const roleList = (p.roles || []).join(', ');
@@ -186,8 +198,10 @@ function logExternalSystemSection(ext) {
186
198
  /**
187
199
  * Format and print human-readable show output (offline or online).
188
200
  * @param {Object} summary - Unified summary (buildOfflineSummaryFromDeployJson or buildOnlineSummary)
201
+ * @param {Object} [options] - Display options
202
+ * @param {boolean} [options.permissionsOnly] - When true, output only source and Permissions section
189
203
  */
190
- function display(summary) {
204
+ function display(summary, options = {}) {
191
205
  const a = summary.application;
192
206
  const roles = summary.roles ?? a.roles ?? [];
193
207
  const permissions = summary.permissions ?? a.permissions ?? [];
@@ -197,9 +211,14 @@ function display(summary) {
197
211
  const dbNames = Array.isArray(databases) ? databases.map((d) => (d && d.name) || d).filter(Boolean) : [];
198
212
 
199
213
  logSourceAndHeader(summary);
214
+ if (options.permissionsOnly) {
215
+ logPermissionsSection(permissions, { showWhenEmpty: true });
216
+ logger.log('');
217
+ return;
218
+ }
200
219
  logApplicationSection(a, summary.isExternal);
201
220
  logRolesSection(roles);
202
- logPermissionsSection(permissions);
221
+ /* Permissions section shown only when --permissions is set (permissionsOnly mode) */
203
222
  logAuthSection(authentication);
204
223
  logConfigurationsSection(portalInputConfigurations);
205
224
  logDatabasesSection(dbNames);