@aifabrix/builder 2.33.5 → 2.36.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.
@@ -3,27 +3,34 @@
3
3
  * @author AI Fabrix Team
4
4
  * @version 2.0.0
5
5
  */
6
- /* eslint-disable max-lines */
6
+
7
7
  const chalk = require('chalk');
8
8
  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 {
16
16
  createWizardSession,
17
17
  updateWizardSession,
18
- parseOpenApi,
19
- credentialSelection,
20
18
  detectType,
21
19
  generateConfig,
22
20
  validateWizardConfig,
23
- getDeploymentDocs,
24
- testMcpConnection
21
+ getDeploymentDocs
25
22
  } = require('../api/wizard.api');
26
23
  const { generateWizardFiles } = require('../generator/wizard');
24
+ const {
25
+ parseOpenApiSource,
26
+ testMcpServerConnection,
27
+ normalizeCredentialSelectionInput,
28
+ runCredentialSelectionLoop,
29
+ buildConfigPreferences,
30
+ buildConfigPayload,
31
+ extractConfigurationFromResponse,
32
+ throwConfigGenerationError
33
+ } = require('./wizard-core-helpers');
27
34
 
28
35
  /**
29
36
  * Validate app name and check if directory exists
@@ -75,6 +82,32 @@ function extractSessionId(responseData) {
75
82
  return sessionId;
76
83
  }
77
84
 
85
+ /**
86
+ * Full error message when dataplane returns 401 (controller accepts token, dataplane rejects it).
87
+ * Avoids misleading "token invalid or expired" / "run login" text from the API formatter.
88
+ * @param {string} [dataplaneUrl] - Dataplane URL (for context)
89
+ * @param {string} [appName] - Application name for credential hint
90
+ * @param {string} [apiMessage] - Raw message from API (e.g. "Invalid token or insufficient permissions")
91
+ * @returns {string} Full message explaining the actual problem
92
+ */
93
+ function formatDataplaneRejectedTokenMessage(dataplaneUrl = '', appName = null, apiMessage = '') {
94
+ const app = appName || '<app>';
95
+ const where = dataplaneUrl ? ` the dataplane at ${dataplaneUrl}` : ' the dataplane';
96
+ const apiLine = apiMessage ? `\n\nResponse: ${apiMessage}` : '';
97
+ return (
98
+ 'Failed to create wizard session.' +
99
+ apiLine +
100
+ `\n\nYour token is valid for the controller (aifabrix auth status shows you as authenticated), but${where} rejected the request.` +
101
+ ' This usually means:\n' +
102
+ ' • The dataplane is configured to accept only client credentials, not device tokens, or\n' +
103
+ ' • There is a permission or configuration issue on the dataplane side.\n\n' +
104
+ 'What you can do:\n' +
105
+ ` • Add client credentials to ~/.aifabrix/secrets.local.yaml as "${app}-client-idKeyVault" and "${app}-client-secretKeyVault" if the dataplane accepts them.\n` +
106
+ ' • Contact your administrator to have the dataplane accept your token or to get the required client credentials.\n' +
107
+ ' • Run "aifabrix doctor" for environment diagnostics.'
108
+ );
109
+ }
110
+
78
111
  /**
79
112
  * Handle mode selection step - create wizard session
80
113
  * @async
@@ -92,7 +125,11 @@ async function handleModeSelection(dataplaneUrl, authConfig, configMode = null,
92
125
  if (!sessionResponse.success || !sessionResponse.data) {
93
126
  const errorMsg = sessionResponse.formattedError || sessionResponse.error ||
94
127
  sessionResponse.errorData?.detail || 'Unknown error';
95
- throw new Error(`Failed to create wizard session: ${errorMsg}`);
128
+ const apiMessage = sessionResponse.errorData?.message || sessionResponse.errorData?.detail || sessionResponse.error || '';
129
+ const fullMsg = sessionResponse.status === 401
130
+ ? formatDataplaneRejectedTokenMessage(dataplaneUrl, null, apiMessage)
131
+ : `Failed to create wizard session: ${errorMsg}`;
132
+ throw new Error(fullMsg);
96
133
  }
97
134
  const sessionId = extractSessionId(sessionResponse.data);
98
135
  logger.log(chalk.green(`\u2713 Session created: ${sessionId}`));
@@ -131,59 +168,6 @@ async function handleSourceSelection(dataplaneUrl, sessionId, authConfig, config
131
168
  return { sourceType, sourceData };
132
169
  }
133
170
 
134
- /**
135
- * Parse OpenAPI file or URL
136
- * @async
137
- * @function parseOpenApiSource
138
- * @param {string} dataplaneUrl - Dataplane URL
139
- * @param {Object} authConfig - Authentication configuration
140
- * @param {string} sourceType - Source type (openapi-file or openapi-url)
141
- * @param {string} sourceData - Source data (file path or URL)
142
- * @returns {Promise<Object|null>} OpenAPI spec or null
143
- */
144
- async function parseOpenApiSource(dataplaneUrl, authConfig, sourceType, sourceData) {
145
- const isUrl = sourceType === 'openapi-url';
146
- const spinner = ora(`Parsing OpenAPI ${isUrl ? 'URL' : 'file'}...`).start();
147
- try {
148
- const parseResponse = await parseOpenApi(dataplaneUrl, authConfig, sourceData, isUrl);
149
- spinner.stop();
150
- if (!parseResponse.success) {
151
- throw new Error(`OpenAPI parsing failed: ${parseResponse.error || parseResponse.formattedError}`);
152
- }
153
- logger.log(chalk.green(`\u2713 OpenAPI ${isUrl ? 'URL' : 'file'} parsed successfully`));
154
- return parseResponse.data?.spec;
155
- } catch (error) {
156
- spinner.stop();
157
- throw error;
158
- }
159
- }
160
-
161
- /**
162
- * Test MCP server connection
163
- * @async
164
- * @function testMcpServerConnection
165
- * @param {string} dataplaneUrl - Dataplane URL
166
- * @param {Object} authConfig - Authentication configuration
167
- * @param {string} sourceData - MCP server details JSON string
168
- * @returns {Promise<null>} Always returns null
169
- */
170
- async function testMcpServerConnection(dataplaneUrl, authConfig, sourceData) {
171
- const mcpDetails = JSON.parse(sourceData);
172
- const spinner = ora('Testing MCP server connection...').start();
173
- try {
174
- const testResponse = await testMcpConnection(dataplaneUrl, authConfig, mcpDetails.serverUrl, mcpDetails.token);
175
- spinner.stop();
176
- if (!testResponse.success || !testResponse.data?.connected) {
177
- throw new Error(`MCP connection failed: ${testResponse.data?.error || 'Unable to connect'}`);
178
- }
179
- logger.log(chalk.green('\u2713 MCP server connection successful'));
180
- } catch (error) {
181
- spinner.stop();
182
- throw error;
183
- }
184
- return null;
185
- }
186
-
187
171
  /**
188
172
  * Handle OpenAPI parsing step
189
173
  * @async
@@ -212,41 +196,28 @@ async function handleOpenApiParsing(dataplaneUrl, authConfig, sourceType, source
212
196
  }
213
197
 
214
198
  /**
215
- * Handle credential selection step
199
+ * Handle credential selection step.
200
+ * Validation is done by the dataplane (POST /api/v1/wizard/credential-selection).
201
+ * When action is 'select' and the API fails (e.g. credential not found), and allowRetry is true,
202
+ * we re-prompt for credential ID/key or allow the user to skip (empty = skip).
216
203
  * @async
217
204
  * @function handleCredentialSelection
218
205
  * @param {string} dataplaneUrl - Dataplane URL
219
206
  * @param {Object} authConfig - Authentication configuration
220
- * @param {Object} [configCredential] - Credential config from wizard.yaml
221
- * @returns {Promise<string|null>} Credential ID/key or null if skipped
207
+ * @param {Object} [configCredential] - Credential config from wizard.yaml or prompt
208
+ * @param {Object} [options] - Options
209
+ * @param {boolean} [options.allowRetry=true] - If true (interactive), re-prompt on failure for 'select'; if false (headless), do not re-prompt
210
+ * @returns {Promise<string|null>} Credential ID/key or null if skipped / failed
222
211
  */
223
- async function handleCredentialSelection(dataplaneUrl, authConfig, configCredential = null) {
212
+ async function handleCredentialSelection(dataplaneUrl, authConfig, configCredential = null, options = {}) {
213
+ const allowRetry = options.allowRetry !== false;
224
214
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 3: Credential Selection (Optional)'));
225
- const selectionData = configCredential ? {
226
- action: configCredential.action,
227
- credentialConfig: configCredential.config,
228
- credentialIdOrKey: configCredential.credentialIdOrKey
229
- } : { action: 'skip' };
215
+ const selectionData = normalizeCredentialSelectionInput(configCredential);
230
216
  if (selectionData.action === 'skip') {
231
217
  logger.log(chalk.gray(' Skipping credential selection'));
232
218
  return null;
233
219
  }
234
- const spinner = ora('Processing credential selection...').start();
235
- try {
236
- const response = await credentialSelection(dataplaneUrl, authConfig, selectionData);
237
- spinner.stop();
238
- if (!response.success) {
239
- logger.log(chalk.yellow(`Warning: Credential selection failed: ${response.error}`));
240
- return null;
241
- }
242
- const actionText = selectionData.action === 'create' ? 'created' : 'selected';
243
- logger.log(chalk.green(`\u2713 Credential ${actionText}`));
244
- return response.data?.credentialIdOrKey || null;
245
- } catch (error) {
246
- spinner.stop();
247
- logger.log(chalk.yellow(`Warning: Credential selection failed: ${error.message}`));
248
- return null;
249
- }
220
+ return await runCredentialSelectionLoop(dataplaneUrl, authConfig, selectionData, allowRetry);
250
221
  }
251
222
 
252
223
  /**
@@ -278,67 +249,6 @@ async function handleTypeDetection(dataplaneUrl, authConfig, openapiSpec) {
278
249
  return null;
279
250
  }
280
251
 
281
- /**
282
- * Build configuration preferences from configPrefs object
283
- * @function buildConfigPreferences
284
- * @param {Object} [configPrefs] - Preferences from wizard.yaml
285
- * @returns {Object} Configuration preferences object
286
- */
287
- function buildConfigPreferences(configPrefs) {
288
- return {
289
- intent: configPrefs?.intent || 'general integration',
290
- fieldOnboardingLevel: configPrefs?.fieldOnboardingLevel || 'full',
291
- enableOpenAPIGeneration: configPrefs?.enableOpenAPIGeneration !== false,
292
- userPreferences: {
293
- enableMCP: configPrefs?.enableMCP || false,
294
- enableABAC: configPrefs?.enableABAC || false,
295
- enableRBAC: configPrefs?.enableRBAC || false
296
- }
297
- };
298
- }
299
-
300
- /**
301
- * Build configuration payload for API call
302
- * @function buildConfigPayload
303
- * @param {Object} params - Parameters object
304
- * @param {Object} params.openapiSpec - OpenAPI specification
305
- * @param {Object} params.detectedType - Detected type info
306
- * @param {string} params.mode - Selected mode
307
- * @param {Object} params.prefs - Configuration preferences
308
- * @param {string} [params.credentialIdOrKey] - Credential ID or key
309
- * @param {string} [params.systemIdOrKey] - System ID or key
310
- * @returns {Object} Configuration payload
311
- */
312
- function buildConfigPayload({ openapiSpec, detectedType, mode, prefs, credentialIdOrKey, systemIdOrKey }) {
313
- const detectedTypeValue = detectedType?.recommendedType || detectedType?.apiType || detectedType?.selectedType || 'record-based';
314
- const payload = {
315
- openapiSpec,
316
- detectedType: detectedTypeValue,
317
- intent: prefs.intent,
318
- mode,
319
- fieldOnboardingLevel: prefs.fieldOnboardingLevel,
320
- enableOpenAPIGeneration: prefs.enableOpenAPIGeneration,
321
- userPreferences: prefs.userPreferences
322
- };
323
- if (credentialIdOrKey) payload.credentialIdOrKey = credentialIdOrKey;
324
- if (systemIdOrKey) payload.systemIdOrKey = systemIdOrKey;
325
- return payload;
326
- }
327
-
328
- /**
329
- * Extract configuration from API response
330
- * @function extractConfigurationFromResponse
331
- * @param {Object} generateResponse - API response
332
- * @returns {Object} Extracted configuration
333
- */
334
- function extractConfigurationFromResponse(generateResponse) {
335
- const systemConfig = generateResponse.data?.systemConfig;
336
- const datasourceConfigs = generateResponse.data?.datasourceConfigs ||
337
- (generateResponse.data?.datasourceConfig ? [generateResponse.data.datasourceConfig] : []);
338
- if (!systemConfig) throw new Error('System configuration not found');
339
- return { systemConfig, datasourceConfigs, systemKey: generateResponse.data?.systemKey };
340
- }
341
-
342
252
  /**
343
253
  * Handle configuration generation step
344
254
  * @async
@@ -354,6 +264,7 @@ function extractConfigurationFromResponse(generateResponse) {
354
264
  * @param {string} [options.systemIdOrKey] - System ID or key (optional)
355
265
  * @returns {Promise<Object>} Generated configuration
356
266
  */
267
+
357
268
  async function handleConfigurationGeneration(dataplaneUrl, authConfig, options) {
358
269
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 5: Generate Configuration'));
359
270
  const prefs = buildConfigPreferences(options.configPrefs);
@@ -370,7 +281,7 @@ async function handleConfigurationGeneration(dataplaneUrl, authConfig, options)
370
281
  const generateResponse = await generateConfig(dataplaneUrl, authConfig, configPayload);
371
282
  spinner.stop();
372
283
  if (!generateResponse.success) {
373
- throw new Error(`Configuration generation failed: ${generateResponse.error || generateResponse.formattedError}`);
284
+ throwConfigGenerationError(generateResponse);
374
285
  }
375
286
  const result = extractConfigurationFromResponse(generateResponse);
376
287
  const normalized = normalizeWizardConfigs(result.systemConfig, result.datasourceConfigs);
@@ -478,20 +389,13 @@ async function setupDataplaneAndAuth(options, appName) {
478
389
  const { resolveEnvironment } = require('../core/config');
479
390
  const environment = await resolveEnvironment();
480
391
  const controllerUrl = await resolveControllerUrl();
392
+ // Prefer device token; use client token or client credentials when available.
393
+ // Some dataplanes accept only client credentials; getDeploymentAuth tries all.
481
394
  let authConfig;
482
395
  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);
396
+ authConfig = await getDeploymentAuth(controllerUrl, environment, appName);
487
397
  } 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
- }
398
+ throw new Error(`Authentication failed: ${error.message}`);
495
399
  }
496
400
  const { resolveDataplaneUrl } = require('../utils/dataplane-resolver');
497
401
  let dataplaneUrl;
@@ -509,7 +413,16 @@ async function setupDataplaneAndAuth(options, appName) {
509
413
  }
510
414
 
511
415
  module.exports = {
512
- validateAndCheckAppDirectory, extractSessionId, handleModeSelection, handleSourceSelection, handleOpenApiParsing,
513
- handleCredentialSelection, handleTypeDetection, handleConfigurationGeneration, validateWizardConfiguration,
514
- handleFileSaving, setupDataplaneAndAuth
416
+ validateAndCheckAppDirectory,
417
+ extractSessionId,
418
+ formatDataplaneRejectedTokenMessage,
419
+ handleModeSelection,
420
+ handleSourceSelection,
421
+ handleOpenApiParsing,
422
+ handleCredentialSelection,
423
+ handleTypeDetection,
424
+ handleConfigurationGeneration,
425
+ validateWizardConfiguration,
426
+ handleFileSaving,
427
+ setupDataplaneAndAuth
515
428
  };
@@ -45,8 +45,8 @@ async function executeWizardFromConfig(wizardConfig, dataplaneUrl, authConfig) {
45
45
  // Parse OpenAPI
46
46
  const openapiSpec = await handleOpenApiParsing(dataplaneUrl, authConfig, sourceType, sourceData);
47
47
 
48
- // Step 3: Credential Selection
49
- const credentialIdOrKey = await handleCredentialSelection(dataplaneUrl, authConfig, credential);
48
+ // Step 3: Credential Selection (no retry prompt in headless)
49
+ const credentialIdOrKey = await handleCredentialSelection(dataplaneUrl, authConfig, credential, { allowRetry: false });
50
50
 
51
51
  // Step 4: Detect Type
52
52
  const detectedType = await handleTypeDetection(dataplaneUrl, authConfig, openapiSpec);
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @fileoverview Wizard command helpers - pure and I/O helpers for wizard flow
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const path = require('path');
8
+ const fs = require('fs').promises;
9
+ const chalk = require('chalk');
10
+ const logger = require('../utils/logger');
11
+
12
+ /**
13
+ * Build preferences object for wizard.yaml (schema shape: intent, fieldOnboardingLevel, enableOpenAPIGeneration, enableMCP, enableABAC, enableRBAC)
14
+ * @param {string} intent - User intent
15
+ * @param {Object} preferences - From promptForUserPreferences (mcp, abac, rbac)
16
+ * @returns {Object} Preferences for wizard-config schema
17
+ */
18
+ function buildPreferencesForSave(intent, preferences) {
19
+ return {
20
+ intent: intent || 'general integration',
21
+ fieldOnboardingLevel: 'full',
22
+ enableOpenAPIGeneration: true,
23
+ enableMCP: Boolean(preferences?.mcp),
24
+ enableABAC: Boolean(preferences?.abac),
25
+ enableRBAC: Boolean(preferences?.rbac)
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Build source object for wizard.yaml (no secrets)
31
+ * @param {Object} [source] - Source state
32
+ * @returns {Object|undefined}
33
+ */
34
+ function buildSourceForSave(source) {
35
+ if (!source) return undefined;
36
+ const out = { type: source.type };
37
+ if (source.type === 'openapi-file' && source.filePath) out.filePath = source.filePath;
38
+ if (source.type === 'openapi-url' && source.url) out.url = source.url;
39
+ if (source.type === 'mcp-server' && source.serverUrl) {
40
+ out.serverUrl = source.serverUrl;
41
+ out.token = source.token ? '(set)' : undefined;
42
+ }
43
+ if (source.type === 'known-platform' && source.platform) out.platform = source.platform;
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Build partial wizard state for saving to wizard.yaml (no secrets)
49
+ * @param {Object} opts - Collected state
50
+ * @returns {Object} Serializable wizard config shape
51
+ */
52
+ function buildWizardStateForSave(opts) {
53
+ const state = {
54
+ appName: opts.appKey,
55
+ mode: opts.mode,
56
+ source: buildSourceForSave(opts.source)
57
+ };
58
+ if (opts.mode === 'add-datasource' && opts.systemIdOrKey) state.systemIdOrKey = opts.systemIdOrKey;
59
+ if (opts.credential) state.credential = opts.credential;
60
+ if (opts.preferences) state.preferences = opts.preferences;
61
+ return state;
62
+ }
63
+
64
+ /**
65
+ * Format source config as a short line for display
66
+ * @param {Object} [source] - Source config
67
+ * @returns {string|null}
68
+ */
69
+ function formatSourceLine(source) {
70
+ if (!source) return null;
71
+ const s = source;
72
+ return s.type + (s.filePath ? ` (${s.filePath})` : s.url ? ` (${s.url})` : s.platform ? ` (${s.platform})` : '');
73
+ }
74
+
75
+ /**
76
+ * Format preferences config as a short line for display
77
+ * @param {Object} [preferences] - Preferences config
78
+ * @returns {string|null}
79
+ */
80
+ function formatPreferencesLine(preferences) {
81
+ if (!preferences || (!preferences.intent && (preferences.enableMCP === undefined || preferences.enableMCP === null))) {
82
+ return null;
83
+ }
84
+ const p = preferences;
85
+ return [p.intent && `intent=${p.intent}`, p.enableMCP && 'MCP', p.enableABAC && 'ABAC', p.enableRBAC && 'RBAC'].filter(Boolean).join(', ') || '(defaults)';
86
+ }
87
+
88
+ /**
89
+ * Show a short summary of loaded wizard config (for resume flow)
90
+ * @param {Object} config - Loaded wizard config
91
+ * @param {string} displayPath - Path to show (e.g. integration/test/wizard.yaml)
92
+ */
93
+ function showWizardConfigSummary(config, displayPath) {
94
+ logger.log(chalk.blue('\n📋 Saved config summary'));
95
+ logger.log(chalk.gray(` From: ${displayPath}`));
96
+ if (config.appName) logger.log(chalk.gray(` App: ${config.appName}`));
97
+ if (config.mode) logger.log(chalk.gray(` Mode: ${config.mode}`));
98
+ const srcLine = formatSourceLine(config.source);
99
+ if (srcLine) logger.log(chalk.gray(` Source: ${srcLine}`));
100
+ if (config.credential) logger.log(chalk.gray(` Credential: ${config.credential.action || 'skip'}`));
101
+ const prefs = formatPreferencesLine(config.preferences);
102
+ if (prefs) logger.log(chalk.gray(` Preferences: ${prefs}`));
103
+ logger.log('');
104
+ }
105
+
106
+ /**
107
+ * Ensure integration/<appKey> directory exists
108
+ * @param {string} appKey - Application key
109
+ * @returns {Promise<string>} Resolved config path (integration/<appKey>/wizard.yaml)
110
+ */
111
+ async function ensureIntegrationDir(appKey) {
112
+ const dir = path.join(process.cwd(), 'integration', appKey);
113
+ await fs.mkdir(dir, { recursive: true });
114
+ return path.join(dir, 'wizard.yaml');
115
+ }
116
+
117
+ /** External system types that support add-datasource (excludes webapp/application) */
118
+ const EXTERNAL_SYSTEM_TYPES = ['openapi', 'mcp', 'custom'];
119
+
120
+ /**
121
+ * Returns true if the system is an external system that supports add-datasource (not a webapp).
122
+ * @param {Object} sys - System object from getExternalSystem (may have type, systemType, or kind)
123
+ * @returns {boolean}
124
+ */
125
+ function isExternalSystemForAddDatasource(sys) {
126
+ const type = (sys?.type || sys?.systemType || sys?.kind || '').toLowerCase();
127
+ if (!type) return true;
128
+ if (EXTERNAL_SYSTEM_TYPES.includes(type)) return true;
129
+ if (['webapp', 'application', 'app'].includes(type)) return false;
130
+ return true;
131
+ }
132
+
133
+ module.exports = {
134
+ buildPreferencesForSave,
135
+ buildSourceForSave,
136
+ buildWizardStateForSave,
137
+ formatSourceLine,
138
+ formatPreferencesLine,
139
+ showWizardConfigSummary,
140
+ ensureIntegrationDir,
141
+ EXTERNAL_SYSTEM_TYPES,
142
+ isExternalSystemForAddDatasource
143
+ };