@aifabrix/builder 2.33.4 → 2.33.6

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.
@@ -139,14 +139,15 @@ async function validateAppConfiguration(appName) {
139
139
  }
140
140
 
141
141
  /**
142
- * Checks prerequisites: Docker image and infrastructure
142
+ * Checks prerequisites: Docker image and (optionally) infrastructure
143
143
  * @async
144
144
  * @param {string} appName - Application name
145
145
  * @param {Object} appConfig - Application configuration
146
146
  * @param {boolean} [debug=false] - Enable debug logging
147
+ * @param {boolean} [skipInfraCheck=false] - When true, skip infra health check (e.g. when caller already verified, e.g. up-miso)
147
148
  * @throws {Error} If prerequisites are not met
148
149
  */
149
- async function checkPrerequisites(appName, appConfig, debug = false) {
150
+ async function checkPrerequisites(appName, appConfig, debug = false, skipInfraCheck = false) {
150
151
  const imageName = composeGenerator.getImageName(appConfig, appName);
151
152
  const imageTag = appConfig.image?.tag || 'latest';
152
153
  const fullImageName = `${imageName}:${imageTag}`;
@@ -162,6 +163,10 @@ async function checkPrerequisites(appName, appConfig, debug = false) {
162
163
  }
163
164
  logger.log(chalk.green(`✓ Image ${fullImageName} found`));
164
165
 
166
+ if (skipInfraCheck) {
167
+ return;
168
+ }
169
+
165
170
  logger.log(chalk.blue('Checking infrastructure health...'));
166
171
  const infraHealth = await infra.checkInfraHealth();
167
172
  if (debug) {
package/lib/app/run.js CHANGED
@@ -177,8 +177,8 @@ async function runApp(appName, options = {}) {
177
177
  // Load and configure application
178
178
  const appConfig = await loadAndConfigureApp(appName, debug);
179
179
 
180
- // Check prerequisites: image and infrastructure
181
- await helpers.checkPrerequisites(appName, appConfig, debug);
180
+ // Check prerequisites: image and (unless skipped) infrastructure
181
+ await helpers.checkPrerequisites(appName, appConfig, debug, options.skipInfraCheck === true);
182
182
 
183
183
  // Check if container is already running and stop it if needed
184
184
  await checkAndStopContainer(appName, appConfig.developerId, debug);
package/lib/cli.js CHANGED
@@ -155,10 +155,10 @@ function setupInfraCommands(program) {
155
155
  });
156
156
 
157
157
  program.command('up-miso')
158
- .description('Install keycloak and miso-controller from images (no build). Infra must be up. Uses auto-generated secrets for testing.')
159
- .option('-r, --registry <url>', 'Override registry for both apps (e.g. myacr.azurecr.io)')
158
+ .description('Install keycloak, miso-controller, and dataplane from images (no build). Infra must be up. Uses auto-generated secrets for testing.')
159
+ .option('-r, --registry <url>', 'Override registry for all apps (e.g. myacr.azurecr.io)')
160
160
  .option('--registry-mode <mode>', 'Override registry mode (acr|external)')
161
- .option('-i, --image <key>=<value>', 'Override image (e.g. keycloak=myreg/keycloak:v1, miso-controller=myreg/m:v1); can be repeated', (v, prev) => (prev || []).concat([v]))
161
+ .option('-i, --image <key>=<value>', 'Override image (e.g. keycloak=myreg/k:v1, miso-controller=myreg/m:v1, dataplane=myreg/d:v1); can be repeated', (v, prev) => (prev || []).concat([v]))
162
162
  .action(async(options) => {
163
163
  try {
164
164
  await handleUpMiso(options);
@@ -410,9 +410,17 @@ function setupAppCommands(program) {
410
410
  });
411
411
 
412
412
  program.command('wizard')
413
- .description('Interactive wizard for creating external systems (or headless with --config)')
414
- .option('-a, --app <app>', 'Application name (if not provided, will prompt)')
415
- .option('--config <file>', 'Path to wizard.yaml config file for headless mode')
413
+ .description('Create or extend external systems (OpenAPI, MCP, or known platforms like HubSpot) via guided steps or a config file')
414
+ .option('-a, --app <app>', 'Application name; skips the prompt in interactive mode')
415
+ .option('--config <file>', 'Run headless using a wizard.yaml file (appName, mode, source, credential, preferences)')
416
+ .addHelpText('after', `
417
+ Examples:
418
+ $ aifabrix wizard Run interactively (prompts for app name and steps)
419
+ $ aifabrix wizard -a my-integration Run interactively with app name set
420
+ $ aifabrix wizard --config wizard.yaml Run headless from a wizard config file
421
+
422
+ Headless config (wizard.yaml) must include: appName, mode (create-system|add-datasource), source (type + filePath/url/platform).
423
+ See integration/hubspot/wizard-hubspot-e2e.yaml for an example.`)
416
424
  .action(async(options) => {
417
425
  try {
418
426
  const { handleWizard } = require('./commands/wizard');
@@ -15,6 +15,9 @@ const { getConfig } = config;
15
15
  const { getOrRefreshDeviceToken } = require('../utils/token-manager');
16
16
  const { getAuthUser } = require('../api/auth.api');
17
17
  const { resolveControllerUrl } = require('../utils/controller-url');
18
+ const { findDataplaneServiceAppKey } = require('./wizard-dataplane');
19
+ const { getDataplaneUrl } = require('../datasource/deploy');
20
+ const { checkDataplaneHealth } = require('../utils/dataplane-health');
18
21
 
19
22
  /**
20
23
  * Format expiration date for display
@@ -212,13 +215,53 @@ function displayTokenInfo(tokenInfo) {
212
215
  displayUserInfo(tokenInfo.user);
213
216
  }
214
217
 
218
+ /**
219
+ * Resolve dataplane URL from controller without progress logs (for auth status display)
220
+ * @async
221
+ * @param {string} controllerUrl - Controller URL
222
+ * @param {string} environment - Environment key
223
+ * @param {Object} authConfig - Authentication configuration
224
+ * @returns {Promise<string|null>} Dataplane URL or null if not discoverable
225
+ */
226
+ async function resolveDataplaneUrlSilent(controllerUrl, environment, authConfig) {
227
+ try {
228
+ const dataplaneAppKey = await findDataplaneServiceAppKey(controllerUrl, environment, authConfig);
229
+ if (dataplaneAppKey) {
230
+ return await getDataplaneUrl(controllerUrl, dataplaneAppKey, environment, authConfig);
231
+ }
232
+ return await getDataplaneUrl(controllerUrl, 'dataplane', environment, authConfig);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Display dataplane section when authenticated
240
+ * @param {string|null} dataplaneUrl - Dataplane URL or null
241
+ * @param {boolean} dataplaneConnected - Whether dataplane health check passed
242
+ */
243
+ function displayDataplaneSection(dataplaneUrl, dataplaneConnected) {
244
+ if (dataplaneUrl) {
245
+ logger.log(`Dataplane: ${chalk.cyan(dataplaneUrl)}`);
246
+ const statusIcon = dataplaneConnected ? chalk.green('✓') : chalk.red('✗');
247
+ const statusText = dataplaneConnected ? 'Connected' : 'Not reachable';
248
+ logger.log(`Status: ${statusIcon} ${statusText}`);
249
+ } else {
250
+ logger.log(`Dataplane: ${chalk.gray('—')}`);
251
+ logger.log(`Status: ${chalk.gray('Not discovered')}`);
252
+ }
253
+ }
254
+
215
255
  /**
216
256
  * Display authentication status
217
257
  * @param {string} controllerUrl - Controller URL
218
258
  * @param {string} environment - Environment key
219
259
  * @param {Object|null} tokenInfo - Token information
260
+ * @param {Object} [dataplaneInfo] - Optional dataplane URL and health
261
+ * @param {string|null} [dataplaneInfo.url] - Dataplane URL
262
+ * @param {boolean} [dataplaneInfo.connected] - Whether dataplane is reachable
220
263
  */
221
- function displayStatus(controllerUrl, environment, tokenInfo) {
264
+ function displayStatus(controllerUrl, environment, tokenInfo, dataplaneInfo) {
222
265
  logger.log(chalk.bold('\n🔐 Authentication Status\n'));
223
266
  logger.log(`Controller: ${chalk.cyan(controllerUrl)}`);
224
267
  logger.log(`Environment: ${chalk.cyan(environment || 'Not specified')}\n`);
@@ -231,6 +274,11 @@ function displayStatus(controllerUrl, environment, tokenInfo) {
231
274
  }
232
275
 
233
276
  displayTokenInfo(tokenInfo);
277
+
278
+ if (tokenInfo.authenticated && dataplaneInfo) {
279
+ displayDataplaneSection(dataplaneInfo.url, dataplaneInfo.connected);
280
+ }
281
+
234
282
  logger.log('');
235
283
  }
236
284
 
@@ -255,7 +303,15 @@ async function handleAuthStatus(_options) {
255
303
  tokenInfo = await checkClientToken(controllerUrl, environment);
256
304
  }
257
305
 
258
- displayStatus(controllerUrl, environment, tokenInfo);
306
+ let dataplaneInfo = null;
307
+ if (tokenInfo && tokenInfo.authenticated && tokenInfo.token) {
308
+ const authConfig = { type: 'bearer', token: tokenInfo.token };
309
+ const dataplaneUrl = await resolveDataplaneUrlSilent(controllerUrl, environment, authConfig);
310
+ const connected = dataplaneUrl ? await checkDataplaneHealth(dataplaneUrl, 5000) : false;
311
+ dataplaneInfo = { url: dataplaneUrl, connected };
312
+ }
313
+
314
+ displayStatus(controllerUrl, environment, tokenInfo, dataplaneInfo);
259
315
  }
260
316
 
261
317
  module.exports = { handleAuthStatus };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AI Fabrix Builder - Up Miso Command
3
3
  *
4
- * Installs miso-controller and keycloak from images (no build).
4
+ * Installs keycloak, miso-controller, and dataplane from images (no build).
5
5
  * Assumes infra is up; sets dev secrets and resolves (no force; existing .env values preserved).
6
6
  *
7
7
  * @fileoverview up-miso command implementation
@@ -25,11 +25,13 @@ const { ensureAppFromTemplate } = require('./up-common');
25
25
  const KEYCLOAK_BASE_PORT = 8082;
26
26
  /** Miso controller base port (dev-config app base) */
27
27
  const MISO_BASE_PORT = 3000;
28
+ /** Dataplane base port (from templates/applications/dataplane/variables.yaml) */
29
+ const _DATAPLANE_BASE_PORT = 3001;
28
30
 
29
31
  /**
30
- * Parse --image options array into map { keycloak?: string, 'miso-controller'?: string }
31
- * @param {string[]|string} imageOpts - Option value(s) e.g. ['keycloak=reg/k:v1', 'miso-controller=reg/m:v1']
32
- * @returns {{ keycloak?: string, 'miso-controller'?: string }}
32
+ * Parse --image options array into map { keycloak?: string, 'miso-controller'?: string, dataplane?: string }
33
+ * @param {string[]|string} imageOpts - Option value(s) e.g. ['keycloak=reg/k:v1', 'miso-controller=reg/m:v1', 'dataplane=reg/d:v1']
34
+ * @returns {{ keycloak?: string, 'miso-controller'?: string, dataplane?: string }}
33
35
  */
34
36
  function parseImageOptions(imageOpts) {
35
37
  const map = {};
@@ -48,7 +50,7 @@ function parseImageOptions(imageOpts) {
48
50
 
49
51
  /**
50
52
  * Build full image ref from registry and app variables (registry/name:tag)
51
- * @param {string} appName - keycloak or miso-controller
53
+ * @param {string} appName - keycloak, miso-controller, or dataplane
52
54
  * @param {string} registry - Registry URL
53
55
  * @returns {string} Full image reference
54
56
  */
@@ -65,7 +67,7 @@ function buildImageRefFromRegistry(appName, registry) {
65
67
  }
66
68
 
67
69
  /**
68
- * Set URL secrets and resolve keycloak + miso-controller (no force; existing .env preserved)
70
+ * Set URL secrets and resolve keycloak + miso-controller + dataplane (no force; existing .env preserved)
69
71
  * @async
70
72
  * @param {number} devIdNum - Developer ID number
71
73
  */
@@ -77,11 +79,12 @@ async function setMisoSecretsAndResolve(devIdNum) {
77
79
  logger.log(chalk.green('✓ Set keycloak and miso-controller URL secrets'));
78
80
  await secrets.generateEnvFile('keycloak', undefined, 'docker', false, true);
79
81
  await secrets.generateEnvFile('miso-controller', undefined, 'docker', false, true);
80
- logger.log(chalk.green(' Resolved keycloak and miso-controller'));
82
+ await secrets.generateEnvFile('dataplane', undefined, 'docker', false, true);
83
+ logger.log(chalk.green('✓ Resolved keycloak, miso-controller, and dataplane'));
81
84
  }
82
85
 
83
86
  /**
84
- * Build run options and run keycloak then miso-controller
87
+ * Build run options and run keycloak, miso-controller, then dataplane
85
88
  * @async
86
89
  * @param {Object} options - Commander options (image, registry, registryMode)
87
90
  */
@@ -89,23 +92,27 @@ async function runMisoApps(options) {
89
92
  const imageMap = parseImageOptions(options.image);
90
93
  const keycloakImage = imageMap.keycloak || (options.registry ? buildImageRefFromRegistry('keycloak', options.registry) : undefined);
91
94
  const misoImage = imageMap['miso-controller'] || (options.registry ? buildImageRefFromRegistry('miso-controller', options.registry) : undefined);
92
- const keycloakRunOpts = { image: keycloakImage, registry: options.registry, registryMode: options.registryMode, skipEnvOutputPath: true };
93
- const misoRunOpts = { image: misoImage, registry: options.registry, registryMode: options.registryMode, skipEnvOutputPath: true };
95
+ const dataplaneImage = imageMap.dataplane || (options.registry ? buildImageRefFromRegistry('dataplane', options.registry) : undefined);
96
+ const keycloakRunOpts = { image: keycloakImage, registry: options.registry, registryMode: options.registryMode, skipEnvOutputPath: true, skipInfraCheck: true };
97
+ const misoRunOpts = { image: misoImage, registry: options.registry, registryMode: options.registryMode, skipEnvOutputPath: true, skipInfraCheck: true };
98
+ const dataplaneRunOpts = { image: dataplaneImage, registry: options.registry, registryMode: options.registryMode, skipEnvOutputPath: true, skipInfraCheck: true };
94
99
  logger.log(chalk.blue('Starting keycloak...'));
95
100
  await app.runApp('keycloak', keycloakRunOpts);
96
101
  logger.log(chalk.blue('Starting miso-controller...'));
97
102
  await app.runApp('miso-controller', misoRunOpts);
103
+ logger.log(chalk.blue('Starting dataplane...'));
104
+ await app.runApp('dataplane', dataplaneRunOpts);
98
105
  }
99
106
 
100
107
  /**
101
- * Handle up-miso command: ensure infra, ensure app dirs, set secrets, resolve (preserve existing .env), run keycloak then miso-controller.
108
+ * Handle up-miso command: ensure infra, ensure app dirs, set secrets, resolve (preserve existing .env), run keycloak, miso-controller, then dataplane.
102
109
  *
103
110
  * @async
104
111
  * @function handleUpMiso
105
112
  * @param {Object} options - Commander options
106
- * @param {string} [options.registry] - Override registry for both apps
113
+ * @param {string} [options.registry] - Override registry for all apps
107
114
  * @param {string} [options.registryMode] - Override registry mode (acr|external)
108
- * @param {string[]|string} [options.image] - Override images e.g. keycloak=reg/k:v1 or miso-controller=reg/m:v1
115
+ * @param {string[]|string} [options.image] - Override images e.g. keycloak=reg/k:v1, miso-controller=reg/m:v1, dataplane=reg/d:v1
109
116
  * @returns {Promise<void>}
110
117
  * @throws {Error} If infra not up or any step fails
111
118
  */
@@ -114,8 +121,9 @@ async function handleUpMiso(options = {}) {
114
121
  if (builderDir) {
115
122
  process.env.AIFABRIX_BUILDER_DIR = builderDir;
116
123
  }
117
- logger.log(chalk.blue('Starting up-miso (keycloak + miso-controller from images)...\n'));
118
- const health = await infra.checkInfraHealth();
124
+ logger.log(chalk.blue('Starting up-miso (keycloak + miso-controller + dataplane from images)...\n'));
125
+ // Strict: only this developer's infra (same as status), so up-miso and status agree
126
+ const health = await infra.checkInfraHealth(undefined, { strict: true });
119
127
  const allHealthy = Object.values(health).every(status => status === 'healthy');
120
128
  if (!allHealthy) {
121
129
  throw new Error('Infrastructure is not up. Run \'aifabrix up\' first.');
@@ -123,11 +131,12 @@ async function handleUpMiso(options = {}) {
123
131
  logger.log(chalk.green('✓ Infrastructure is up'));
124
132
  await ensureAppFromTemplate('keycloak');
125
133
  await ensureAppFromTemplate('miso-controller');
134
+ await ensureAppFromTemplate('dataplane');
126
135
  const developerId = await config.getDeveloperId();
127
136
  const devIdNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
128
137
  await setMisoSecretsAndResolve(devIdNum);
129
138
  await runMisoApps(options);
130
- logger.log(chalk.green('\n✓ up-miso complete. Keycloak and miso-controller are running.'));
139
+ logger.log(chalk.green('\n✓ up-miso complete. Keycloak, miso-controller, and dataplane are running.'));
131
140
  logger.log(chalk.gray(' Run onboarding and register Keycloak from the miso-controller repo if needed.'));
132
141
  }
133
142
 
@@ -9,7 +9,7 @@ const ora = require('ora');
9
9
  const path = require('path');
10
10
  const fs = require('fs').promises;
11
11
  const logger = require('../utils/logger');
12
- const { getDeploymentAuth, getDeviceOnlyAuth } = require('../utils/token-manager');
12
+ const { getDeploymentAuth } = require('../utils/token-manager');
13
13
  const { resolveControllerUrl } = require('../utils/controller-url');
14
14
  const { normalizeWizardConfigs } = require('./wizard-config-normalizer');
15
15
  const {
@@ -75,6 +75,32 @@ function extractSessionId(responseData) {
75
75
  return sessionId;
76
76
  }
77
77
 
78
+ /**
79
+ * Full error message when dataplane returns 401 (controller accepts token, dataplane rejects it).
80
+ * Avoids misleading "token invalid or expired" / "run login" text from the API formatter.
81
+ * @param {string} [dataplaneUrl] - Dataplane URL (for context)
82
+ * @param {string} [appName] - Application name for credential hint
83
+ * @param {string} [apiMessage] - Raw message from API (e.g. "Invalid token or insufficient permissions")
84
+ * @returns {string} Full message explaining the actual problem
85
+ */
86
+ function formatDataplaneRejectedTokenMessage(dataplaneUrl = '', appName = null, apiMessage = '') {
87
+ const app = appName || '<app>';
88
+ const where = dataplaneUrl ? ` the dataplane at ${dataplaneUrl}` : ' the dataplane';
89
+ const apiLine = apiMessage ? `\n\nResponse: ${apiMessage}` : '';
90
+ return (
91
+ 'Failed to create wizard session.' +
92
+ apiLine +
93
+ `\n\nYour token is valid for the controller (aifabrix auth status shows you as authenticated), but${where} rejected the request.` +
94
+ ' This usually means:\n' +
95
+ ' • The dataplane is configured to accept only client credentials, not device tokens, or\n' +
96
+ ' • There is a permission or configuration issue on the dataplane side.\n\n' +
97
+ 'What you can do:\n' +
98
+ ` • Add client credentials to ~/.aifabrix/secrets.local.yaml as "${app}-client-idKeyVault" and "${app}-client-secretKeyVault" if the dataplane accepts them.\n` +
99
+ ' • Contact your administrator to have the dataplane accept your token or to get the required client credentials.\n' +
100
+ ' • Run "aifabrix doctor" for environment diagnostics.'
101
+ );
102
+ }
103
+
78
104
  /**
79
105
  * Handle mode selection step - create wizard session
80
106
  * @async
@@ -92,7 +118,11 @@ async function handleModeSelection(dataplaneUrl, authConfig, configMode = null,
92
118
  if (!sessionResponse.success || !sessionResponse.data) {
93
119
  const errorMsg = sessionResponse.formattedError || sessionResponse.error ||
94
120
  sessionResponse.errorData?.detail || 'Unknown error';
95
- throw new Error(`Failed to create wizard session: ${errorMsg}`);
121
+ const apiMessage = sessionResponse.errorData?.message || sessionResponse.errorData?.detail || sessionResponse.error || '';
122
+ const fullMsg = sessionResponse.status === 401
123
+ ? formatDataplaneRejectedTokenMessage(dataplaneUrl, null, apiMessage)
124
+ : `Failed to create wizard session: ${errorMsg}`;
125
+ throw new Error(fullMsg);
96
126
  }
97
127
  const sessionId = extractSessionId(sessionResponse.data);
98
128
  logger.log(chalk.green(`\u2713 Session created: ${sessionId}`));
@@ -478,20 +508,13 @@ async function setupDataplaneAndAuth(options, appName) {
478
508
  const { resolveEnvironment } = require('../core/config');
479
509
  const environment = await resolveEnvironment();
480
510
  const controllerUrl = await resolveControllerUrl();
511
+ // Prefer device token; use client token or client credentials when available.
512
+ // Some dataplanes accept only client credentials; getDeploymentAuth tries all.
481
513
  let authConfig;
482
514
  try {
483
- // For wizard mode creating new external systems, use device-only auth
484
- // since the app doesn't exist yet. Device token is sufficient for
485
- // discovering the dataplane URL and running the wizard.
486
- authConfig = await getDeviceOnlyAuth(controllerUrl);
515
+ authConfig = await getDeploymentAuth(controllerUrl, environment, appName);
487
516
  } catch (error) {
488
- // Fallback to getDeploymentAuth if device-only auth fails
489
- // (e.g., for add-datasource mode where app might exist)
490
- try {
491
- authConfig = await getDeploymentAuth(controllerUrl, environment, appName);
492
- } catch (fallbackError) {
493
- throw new Error(`Authentication failed: ${error.message}`);
494
- }
517
+ throw new Error(`Authentication failed: ${error.message}`);
495
518
  }
496
519
  const { resolveDataplaneUrl } = require('../utils/dataplane-resolver');
497
520
  let dataplaneUrl;
@@ -509,7 +532,16 @@ async function setupDataplaneAndAuth(options, appName) {
509
532
  }
510
533
 
511
534
  module.exports = {
512
- validateAndCheckAppDirectory, extractSessionId, handleModeSelection, handleSourceSelection, handleOpenApiParsing,
513
- handleCredentialSelection, handleTypeDetection, handleConfigurationGeneration, validateWizardConfiguration,
514
- handleFileSaving, setupDataplaneAndAuth
535
+ validateAndCheckAppDirectory,
536
+ extractSessionId,
537
+ formatDataplaneRejectedTokenMessage,
538
+ handleModeSelection,
539
+ handleSourceSelection,
540
+ handleOpenApiParsing,
541
+ handleCredentialSelection,
542
+ handleTypeDetection,
543
+ handleConfigurationGeneration,
544
+ validateWizardConfiguration,
545
+ handleFileSaving,
546
+ setupDataplaneAndAuth
515
547
  };
@@ -21,6 +21,7 @@ const {
21
21
  } = require('../generator/wizard-prompts');
22
22
  const {
23
23
  validateAndCheckAppDirectory,
24
+ formatDataplaneRejectedTokenMessage,
24
25
  handleOpenApiParsing,
25
26
  handleCredentialSelection,
26
27
  handleTypeDetection,
@@ -57,9 +58,10 @@ function extractSessionId(responseData) {
57
58
  * @function handleInteractiveModeSelection
58
59
  * @param {string} dataplaneUrl - Dataplane URL
59
60
  * @param {Object} authConfig - Authentication configuration
61
+ * @param {string} [appName] - Application name (for 401 hint)
60
62
  * @returns {Promise<Object>} Object with mode and sessionId
61
63
  */
62
- async function handleInteractiveModeSelection(dataplaneUrl, authConfig) {
64
+ async function handleInteractiveModeSelection(dataplaneUrl, authConfig, appName) {
63
65
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 1: Mode Selection'));
64
66
  const mode = await promptForMode();
65
67
  let systemIdOrKey = null;
@@ -71,7 +73,11 @@ async function handleInteractiveModeSelection(dataplaneUrl, authConfig) {
71
73
  const errorMsg = sessionResponse.formattedError || sessionResponse.error ||
72
74
  sessionResponse.errorData?.detail || sessionResponse.message ||
73
75
  (sessionResponse.status ? `HTTP ${sessionResponse.status}` : 'Unknown error');
74
- throw new Error(`Failed to create wizard session: ${errorMsg}`);
76
+ const apiMessage = sessionResponse.errorData?.message || sessionResponse.errorData?.detail || sessionResponse.error || '';
77
+ const fullMsg = sessionResponse.status === 401
78
+ ? formatDataplaneRejectedTokenMessage(dataplaneUrl, appName, apiMessage)
79
+ : `Failed to create wizard session: ${errorMsg}`;
80
+ throw new Error(fullMsg);
75
81
  }
76
82
  const sessionId = extractSessionId(sessionResponse.data);
77
83
  return { mode, sessionId, systemIdOrKey };
@@ -187,7 +193,7 @@ async function handleConfigurationReview(dataplaneUrl, authConfig, systemConfig,
187
193
  */
188
194
  async function executeWizardFlow(appName, dataplaneUrl, authConfig) {
189
195
  // Step 1: Mode Selection
190
- const { mode, sessionId, systemIdOrKey } = await handleInteractiveModeSelection(dataplaneUrl, authConfig);
196
+ const { mode, sessionId, systemIdOrKey } = await handleInteractiveModeSelection(dataplaneUrl, authConfig, appName);
191
197
 
192
198
  // Step 2: Source Selection
193
199
  const { sourceType, sourceData } = await handleInteractiveSourceSelection(dataplaneUrl, sessionId, authConfig);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Datasource List Command
3
3
  *
4
- * Lists datasources from an environment via dataplane API.
5
- * Gets dataplane URL from controller, then lists datasources from dataplane.
4
+ * Lists datasources from the dataplane (GET /api/v1/external/).
5
+ * Resolves dataplane URL from the controller, then calls the dataplane list API.
6
6
  *
7
7
  * @fileoverview Datasource listing for AI Fabrix Builder
8
8
  * @author AI Fabrix Team
@@ -12,7 +12,7 @@
12
12
  const chalk = require('chalk');
13
13
  const { getConfig, resolveEnvironment } = require('../core/config');
14
14
  const { getOrRefreshDeviceToken } = require('../utils/token-manager');
15
- const { listDatasources: listDatasourcesFromDataplane } = require('../api/datasources-core.api');
15
+ const { listDatasources: listDatasourcesFromDataplane } = require('../api/datasources.api');
16
16
  const { resolveDataplaneUrl } = require('../utils/dataplane-resolver');
17
17
  const { formatApiError } = require('../utils/api-error-handler');
18
18
  const logger = require('../utils/logger');
@@ -291,8 +291,11 @@ async function listDatasources(_options) {
291
291
 
292
292
  const controllerUrl = validateControllerUrl(authInfo.controllerUrl);
293
293
  const authConfig = setupAuthConfig(authInfo.token, controllerUrl);
294
+
295
+ // Resolve dataplane URL first (required for list call)
294
296
  const dataplaneUrl = await resolveAndValidateDataplaneUrl(controllerUrl, environment, authConfig);
295
297
 
298
+ // List datasources from dataplane (GET /api/v1/external/)
296
299
  const response = await listDatasourcesFromDataplane(dataplaneUrl, authConfig);
297
300
 
298
301
  if (!response.success || !response.data) {
@@ -132,6 +132,8 @@ async function waitForServices(devId = null) {
132
132
  * @async
133
133
  * @function checkInfraHealth
134
134
  * @param {number|string|null} [devId] - Developer ID (null = use current)
135
+ * @param {Object} [options] - Options
136
+ * @param {boolean} [options.strict=false] - When true, only consider current dev's containers (no fallback to dev 0); use for up-miso and status consistency
135
137
  * @returns {Promise<Object>} Health status of each service
136
138
  * @throws {Error} If health check fails
137
139
  *
@@ -139,20 +141,21 @@ async function waitForServices(devId = null) {
139
141
  * const health = await checkInfraHealth();
140
142
  * // Returns: { postgres: 'healthy', redis: 'healthy', pgadmin: 'healthy', redis-commander: 'healthy' }
141
143
  */
142
- async function checkInfraHealth(devId = null) {
144
+ async function checkInfraHealth(devId = null, options = {}) {
143
145
  const developerId = devId || await config.getDeveloperId();
144
146
  const servicesWithHealthCheck = ['postgres', 'redis'];
145
147
  const servicesWithoutHealthCheck = ['pgadmin', 'redis-commander'];
146
148
  const health = {};
149
+ const lookupOptions = options.strict ? { strict: true } : {};
147
150
 
148
151
  // Check health status for services with health checks
149
152
  for (const service of servicesWithHealthCheck) {
150
- health[service] = await containerUtils.checkServiceWithHealthCheck(service, developerId);
153
+ health[service] = await containerUtils.checkServiceWithHealthCheck(service, developerId, lookupOptions);
151
154
  }
152
155
 
153
156
  // Check if services without health checks are running
154
157
  for (const service of servicesWithoutHealthCheck) {
155
- health[service] = await containerUtils.checkServiceWithoutHealthCheck(service, developerId);
158
+ health[service] = await containerUtils.checkServiceWithoutHealthCheck(service, developerId, lookupOptions);
156
159
  }
157
160
 
158
161
  return health;
@@ -138,6 +138,7 @@ async function tryFindDeviceTokenFromConfig(deviceConfig, attemptedUrls) {
138
138
 
139
139
  /**
140
140
  * Creates error data for authentication failure
141
+ * Deduplicates attemptedUrls so the same URL is not shown twice (e.g. when controller URL and device config URL match).
141
142
  * @function createAuthErrorData
142
143
  * @param {Error|null} lastError - Last error encountered
143
144
  * @param {string|null} controllerUrl - Original controller URL
@@ -145,10 +146,16 @@ async function tryFindDeviceTokenFromConfig(deviceConfig, attemptedUrls) {
145
146
  * @returns {Object} Error data object
146
147
  */
147
148
  function createAuthErrorData(lastError, controllerUrl, attemptedUrls) {
149
+ const seen = new Set();
150
+ const uniqueUrls = attemptedUrls.filter(u => {
151
+ if (seen.has(u)) return false;
152
+ seen.add(u);
153
+ return true;
154
+ });
148
155
  return {
149
156
  message: lastError ? lastError.message : 'No valid authentication found',
150
- controllerUrl: controllerUrl || (attemptedUrls.length > 0 ? attemptedUrls[0] : undefined),
151
- attemptedUrls: attemptedUrls.length > 1 ? attemptedUrls : undefined,
157
+ controllerUrl: controllerUrl || (uniqueUrls.length > 0 ? uniqueUrls[0] : undefined),
158
+ attemptedUrls: uniqueUrls.length > 1 ? uniqueUrls : undefined,
152
159
  correlationId: undefined
153
160
  };
154
161
  }
@@ -75,6 +75,19 @@ function mergeEnvConfigs(baseConfig, userConfig) {
75
75
  return merged;
76
76
  }
77
77
 
78
+ /**
79
+ * Load schema-only env-config (no user merge). Used for *_PUBLIC_PORT calculation
80
+ * so public ports always use canonical base (e.g. KEYCLOAK_PUBLIC_PORT = 8082 + devId*100).
81
+ *
82
+ * @function loadSchemaEnvConfig
83
+ * @returns {Object} Parsed schema env-config (environments.docker / .local only)
84
+ */
85
+ function loadSchemaEnvConfig() {
86
+ const envConfigPath = path.join(__dirname, '..', 'schema', 'env-config.yaml');
87
+ const content = fs.readFileSync(envConfigPath, 'utf8');
88
+ return yaml.load(content) || {};
89
+ }
90
+
78
91
  /**
79
92
  * Load env config YAML used for environment variable interpolation
80
93
  * Loads base config from lib/schema/env-config.yaml and merges with user config if configured
@@ -84,10 +97,7 @@ function mergeEnvConfigs(baseConfig, userConfig) {
84
97
  * @throws {Error} If base file cannot be read or parsed
85
98
  */
86
99
  async function loadEnvConfig() {
87
- // Load base env-config.yaml
88
- const envConfigPath = path.join(__dirname, '..', 'schema', 'env-config.yaml');
89
- const content = fs.readFileSync(envConfigPath, 'utf8');
90
- const baseConfig = yaml.load(content) || {};
100
+ const baseConfig = loadSchemaEnvConfig();
91
101
 
92
102
  // Load user env-config if configured
93
103
  const userConfig = await loadUserEnvConfig();
@@ -97,6 +107,7 @@ async function loadEnvConfig() {
97
107
  }
98
108
 
99
109
  module.exports = {
100
- loadEnvConfig
110
+ loadEnvConfig,
111
+ loadSchemaEnvConfig
101
112
  };
102
113
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const yaml = require('js-yaml');
13
- const { loadEnvConfig } = require('./env-config-loader');
13
+ const { loadEnvConfig, loadSchemaEnvConfig } = require('./env-config-loader');
14
14
  const config = require('../core/config');
15
15
 
16
16
  /**
@@ -233,15 +233,15 @@ function applyLocalPortAdjustment(result, devIdNum) {
233
233
 
234
234
  /**
235
235
  * Calculate public ports for docker context
236
- * Uses base ports from env-config when available so *_PUBLIC_PORT is always base + devId*100
237
- * (e.g. KEYCLOAK_PUBLIC_PORT = 8082 + 600 = 8682 for dev 6, not 8080 + 600 from overrides)
236
+ * Uses schema env-config ports only so *_PUBLIC_PORT is always canonical base + devId*100
237
+ * (e.g. KEYCLOAK_PUBLIC_PORT = 8082 + 600 = 8682 for dev 6, even if user env-config or config overrides KEYCLOAK_PORT to 8080)
238
238
  *
239
239
  * @function calculateDockerPublicPorts
240
240
  * @param {Object} result - Environment variable map (merged base + overrides)
241
241
  * @param {number} devIdNum - Developer ID number
242
- * @param {Object} [baseVars] - Base vars from env-config (used for *_PUBLIC_PORT calculation when present)
242
+ * @param {Object} [schemaBaseVars] - Schema-only env-config vars (lib/schema/env-config.yaml) for *_PUBLIC_PORT
243
243
  */
244
- function calculateDockerPublicPorts(result, devIdNum, baseVars = {}) {
244
+ function calculateDockerPublicPorts(result, devIdNum, schemaBaseVars = {}) {
245
245
  if (devIdNum <= 0) {
246
246
  return;
247
247
  }
@@ -249,9 +249,9 @@ function calculateDockerPublicPorts(result, devIdNum, baseVars = {}) {
249
249
  // Match any variable ending with _PORT (e.g., MISO_PORT, KEYCLOAK_PORT, DB_PORT)
250
250
  if (/_PORT$/.test(key) && !/_PUBLIC_PORT$/.test(key)) {
251
251
  const publicPortKey = key.replace(/_PORT$/, '_PUBLIC_PORT');
252
- // Use base port from env-config when available so PUBLIC_PORT is canonical (e.g. 8082 not 8080)
253
- const basePort = baseVars[key];
254
- const sourceVal = basePort !== undefined && basePort !== null ? basePort : value;
252
+ // Use schema port when available so PUBLIC_PORT is canonical (e.g. 8082), not overridden (e.g. 8080)
253
+ const schemaPort = schemaBaseVars[key];
254
+ const sourceVal = schemaPort !== undefined && schemaPort !== null ? schemaPort : value;
255
255
  let portVal;
256
256
  if (typeof sourceVal === 'string') {
257
257
  portVal = parseInt(sourceVal, 10);
@@ -294,7 +294,9 @@ async function buildEnvVarMap(context, osModule = null, developerId = null) {
294
294
  applyLocalPortAdjustment(result, devIdNum);
295
295
  } else if (context === 'docker') {
296
296
  const devIdNum = await getDeveloperIdNumber(developerId);
297
- calculateDockerPublicPorts(result, devIdNum, baseVars);
297
+ const schemaCfg = loadSchemaEnvConfig();
298
+ const schemaBaseVars = (schemaCfg && schemaCfg.environments && schemaCfg.environments[context]) ? schemaCfg.environments[context] : {};
299
+ calculateDockerPublicPorts(result, devIdNum, schemaBaseVars);
298
300
  }
299
301
 
300
302
  return result;
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  const chalk = require('chalk');
12
+ const { getPermissionDetailLines } = require('./permission-errors');
12
13
 
13
14
  /**
14
15
  * Formats authentication error
@@ -108,6 +109,13 @@ function formatAuthenticationError(errorData) {
108
109
  addControllerUrlInfo(lines, errorData);
109
110
  addAttemptedUrlsInfo(lines, errorData);
110
111
  addErrorMessageIfNotGeneric(lines, errorData);
112
+
113
+ // Show permission details when API returns them (401 "insufficient permissions" etc.)
114
+ const permissionLines = getPermissionDetailLines(errorData);
115
+ if (permissionLines.length > 0) {
116
+ lines.push(...permissionLines);
117
+ }
118
+
111
119
  addAuthenticationGuidance(lines, errorData);
112
120
 
113
121
  return lines.join('\n');
@@ -12,31 +12,44 @@ const chalk = require('chalk');
12
12
 
13
13
  /**
14
14
  * Extracts missing permissions from error data
15
+ * Supports: missingPermissions, missing.permissions, data.missing.permissions
15
16
  * @param {Object} errorData - Error response data
16
17
  * @returns {Array<string>} Array of missing permissions
17
18
  */
18
19
  function extractMissingPermissions(errorData) {
20
+ if (!errorData || typeof errorData !== 'object') return [];
19
21
  if (errorData.missingPermissions && Array.isArray(errorData.missingPermissions)) {
20
22
  return errorData.missingPermissions;
21
23
  }
22
24
  if (errorData.missing && errorData.missing.permissions && Array.isArray(errorData.missing.permissions)) {
23
25
  return errorData.missing.permissions;
24
26
  }
27
+ if (errorData.data?.missing?.permissions && Array.isArray(errorData.data.missing.permissions)) {
28
+ return errorData.data.missing.permissions;
29
+ }
25
30
  return [];
26
31
  }
27
32
 
28
33
  /**
29
34
  * Extracts required permissions from error data
35
+ * Supports: requiredPermissions, required.permissions, permissions (array), data.required.permissions
30
36
  * @param {Object} errorData - Error response data
31
37
  * @returns {Array<string>} Array of required permissions
32
38
  */
33
39
  function extractRequiredPermissions(errorData) {
40
+ if (!errorData || typeof errorData !== 'object') return [];
34
41
  if (errorData.requiredPermissions && Array.isArray(errorData.requiredPermissions)) {
35
42
  return errorData.requiredPermissions;
36
43
  }
37
44
  if (errorData.required && errorData.required.permissions && Array.isArray(errorData.required.permissions)) {
38
45
  return errorData.required.permissions;
39
46
  }
47
+ if (errorData.permissions && Array.isArray(errorData.permissions)) {
48
+ return errorData.permissions;
49
+ }
50
+ if (errorData.data?.required?.permissions && Array.isArray(errorData.data.required.permissions)) {
51
+ return errorData.data.required.permissions;
52
+ }
40
53
  return [];
41
54
  }
42
55
 
@@ -56,6 +69,33 @@ function addPermissionList(lines, perms, label) {
56
69
  }
57
70
  }
58
71
 
72
+ /**
73
+ * Returns permission detail lines for appending to any error formatter (401, 403).
74
+ * Use when the API returns required/missing permissions in the error body.
75
+ * @param {Object} errorData - Error response data (may include required.permissions, missing.permissions, etc.)
76
+ * @returns {string[]} Array of formatted lines to append (may be empty)
77
+ */
78
+ function getPermissionDetailLines(errorData) {
79
+ const lines = [];
80
+ const missingPerms = extractMissingPermissions(errorData);
81
+ const requiredPerms = extractRequiredPermissions(errorData);
82
+ if (missingPerms.length > 0) {
83
+ lines.push(chalk.yellow('Missing permissions:'));
84
+ missingPerms.forEach(perm => {
85
+ lines.push(chalk.gray(` - ${perm}`));
86
+ });
87
+ lines.push('');
88
+ }
89
+ if (requiredPerms.length > 0) {
90
+ lines.push(chalk.yellow('Required permissions:'));
91
+ requiredPerms.forEach(perm => {
92
+ lines.push(chalk.gray(` - ${perm}`));
93
+ });
94
+ lines.push('');
95
+ }
96
+ return lines;
97
+ }
98
+
59
99
  /**
60
100
  * Formats permission error with missing and required permissions
61
101
  * @param {Object} errorData - Error response data
@@ -89,6 +129,9 @@ function formatPermissionError(errorData) {
89
129
  }
90
130
 
91
131
  module.exports = {
92
- formatPermissionError
132
+ formatPermissionError,
133
+ getPermissionDetailLines,
134
+ extractMissingPermissions,
135
+ extractRequiredPermissions
93
136
  };
94
137
 
@@ -21,24 +21,25 @@ const execAsync = promisify(exec);
21
21
  * @async
22
22
  * @param {string} serviceName - Service name
23
23
  * @param {number|string} [devId] - Developer ID (optional, will be loaded from config if not provided)
24
+ * @param {Object} [options] - Options
25
+ * @param {boolean} [options.strict=false] - When true, only match current dev's container (no fallback to dev 0 / infra-*); use for status display
24
26
  * @returns {Promise<string|null>} Container name or null if not found
25
27
  */
26
- async function findContainer(serviceName, devId = null) {
28
+ async function findContainer(serviceName, devId = null, options = {}) {
27
29
  try {
28
30
  const developerId = devId || await config.getDeveloperId();
29
31
  const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
30
32
  // Dev 0: aifabrix-{serviceName}, Dev > 0: aifabrix-dev{id}-{serviceName}
31
33
  const primaryPattern = idNum === 0 ? `aifabrix-${serviceName}` : `aifabrix-dev${developerId}-${serviceName}`;
32
34
 
33
- // Search order expected by tests:
34
- // 1) primaryPattern
35
- // 2) infra-{serviceName} (old pattern)
36
- // 3) aifabrix-{serviceName} (base pattern)
37
- const patternsToTry = [
38
- primaryPattern,
39
- `infra-${serviceName}`,
40
- `aifabrix-${serviceName}`
41
- ];
35
+ // When strict (e.g. status command), only show this developer's infra; no fallback to dev 0
36
+ const patternsToTry = options.strict
37
+ ? [primaryPattern]
38
+ : [
39
+ primaryPattern,
40
+ `infra-${serviceName}`,
41
+ `aifabrix-${serviceName}`
42
+ ];
42
43
 
43
44
  for (const pattern of patternsToTry) {
44
45
  const { stdout } = await execAsync(`docker ps --filter "name=${pattern}" --format "{{.Names}}"`);
@@ -64,12 +65,13 @@ async function findContainer(serviceName, devId = null) {
64
65
  * @private
65
66
  * @async
66
67
  * @param {string} serviceName - Service name
67
- * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
68
+ * @param {number|string} [devId] - Developer ID (optional, will be loaded from config if not provided)
69
+ * @param {Object} [options] - Options (e.g. { strict: true } for current dev only)
68
70
  * @returns {Promise<string>} Health status
69
71
  */
70
- async function checkServiceWithHealthCheck(serviceName, devId = null) {
72
+ async function checkServiceWithHealthCheck(serviceName, devId = null, options = {}) {
71
73
  try {
72
- const containerName = await findContainer(serviceName, devId);
74
+ const containerName = await findContainer(serviceName, devId, options);
73
75
  if (!containerName) {
74
76
  return 'unknown';
75
77
  }
@@ -87,12 +89,13 @@ async function checkServiceWithHealthCheck(serviceName, devId = null) {
87
89
  * @private
88
90
  * @async
89
91
  * @param {string} serviceName - Service name
90
- * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
92
+ * @param {number|string} [devId] - Developer ID (optional, will be loaded from config if not provided)
93
+ * @param {Object} [options] - Options (e.g. { strict: true } for current dev only)
91
94
  * @returns {Promise<string>} Health status
92
95
  */
93
- async function checkServiceWithoutHealthCheck(serviceName, devId = null) {
96
+ async function checkServiceWithoutHealthCheck(serviceName, devId = null, options = {}) {
94
97
  try {
95
- const containerName = await findContainer(serviceName, devId);
98
+ const containerName = await findContainer(serviceName, devId, options);
96
99
  if (!containerName) {
97
100
  return 'unknown';
98
101
  }
@@ -40,7 +40,7 @@ async function getInfraStatus() {
40
40
  pgadmin: { port: ports.pgadmin, url: `http://localhost:${ports.pgadmin}` },
41
41
  'redis-commander': { port: ports.redisCommander, url: `http://localhost:${ports.redisCommander}` },
42
42
  traefik: {
43
- port: `${ports.traefikHttp}/${ports.traefikHttps}`,
43
+ port: `${ports.traefikHttp}, ${ports.traefikHttps}`,
44
44
  url: `http://localhost:${ports.traefikHttp}, https://localhost:${ports.traefikHttps}`
45
45
  }
46
46
  };
@@ -49,7 +49,8 @@ async function getInfraStatus() {
49
49
 
50
50
  for (const [serviceName, serviceConfig] of Object.entries(services)) {
51
51
  try {
52
- const containerName = await containerUtils.findContainer(serviceName, devId);
52
+ // Strict: only this developer's infra (no fallback to dev 0), so status reflects reality
53
+ const containerName = await containerUtils.findContainer(serviceName, devId, { strict: true });
53
54
  if (containerName) {
54
55
  const { stdout } = await execAsync(`docker inspect --format='{{.State.Status}}' ${containerName}`);
55
56
  // Normalize status value (trim whitespace and remove quotes)
@@ -97,6 +98,9 @@ function getInfraContainerNames(devIdNum, devId) {
97
98
  ];
98
99
  }
99
100
 
101
+ /** Suffixes for init/helper containers to exclude from "Running Applications" (e.g. keycloak-db-init) */
102
+ const INIT_CONTAINER_SUFFIXES = ['-db-init', '-init'];
103
+
100
104
  /**
101
105
  * Extracts app name from container name
102
106
  * @param {string} containerName - Container name
@@ -107,7 +111,12 @@ function getInfraContainerNames(devIdNum, devId) {
107
111
  function extractAppName(containerName, devIdNum, devId) {
108
112
  const pattern = devIdNum === 0 ? /^aifabrix-(.+)$/ : new RegExp(`^aifabrix-dev${devId}-(.+)$`);
109
113
  const match = containerName.match(pattern);
110
- return match ? match[1] : null;
114
+ if (!match) return null;
115
+ const appName = match[1];
116
+ if (INIT_CONTAINER_SUFFIXES.some(suffix => appName.endsWith(suffix))) {
117
+ return null;
118
+ }
119
+ return appName;
111
120
  }
112
121
 
113
122
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.33.4",
3
+ "version": "2.33.6",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {