@aifabrix/builder 2.33.6 → 2.36.1

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.
@@ -0,0 +1,152 @@
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 (fieldOnboardingLevel, mcp, abac, rbac)
16
+ * @returns {Object} Preferences for wizard-config schema
17
+ */
18
+ function buildPreferencesForSave(intent, preferences) {
19
+ const level = preferences?.fieldOnboardingLevel;
20
+ const validLevel = level === 'standard' || level === 'minimal' ? level : 'full';
21
+ return {
22
+ intent: intent || 'general integration',
23
+ fieldOnboardingLevel: validLevel,
24
+ enableOpenAPIGeneration: true,
25
+ enableMCP: Boolean(preferences?.mcp),
26
+ enableABAC: Boolean(preferences?.abac),
27
+ enableRBAC: Boolean(preferences?.rbac)
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Build source object for wizard.yaml (no secrets)
33
+ * @param {Object} [source] - Source state
34
+ * @returns {Object|undefined}
35
+ */
36
+ function buildSourceForSave(source) {
37
+ if (!source) return undefined;
38
+ const out = { type: source.type };
39
+ if (source.type === 'openapi-file' && source.filePath) out.filePath = source.filePath;
40
+ if (source.type === 'openapi-url' && source.url) out.url = source.url;
41
+ if (source.type === 'mcp-server' && source.serverUrl) {
42
+ out.serverUrl = source.serverUrl;
43
+ out.token = source.token ? '(set)' : undefined;
44
+ }
45
+ if (source.type === 'known-platform' && source.platform) out.platform = source.platform;
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Build partial wizard state for saving to wizard.yaml (no secrets)
51
+ * @param {Object} opts - Collected state
52
+ * @returns {Object} Serializable wizard config shape
53
+ */
54
+ function buildWizardStateForSave(opts) {
55
+ const state = {
56
+ appName: opts.appKey,
57
+ mode: opts.mode,
58
+ source: buildSourceForSave(opts.source)
59
+ };
60
+ if (opts.mode === 'add-datasource' && opts.systemIdOrKey) state.systemIdOrKey = opts.systemIdOrKey;
61
+ if (opts.credential) state.credential = opts.credential;
62
+ if (opts.preferences) state.preferences = opts.preferences;
63
+ return state;
64
+ }
65
+
66
+ /**
67
+ * Format source config as a short line for display
68
+ * @param {Object} [source] - Source config
69
+ * @returns {string|null}
70
+ */
71
+ function formatSourceLine(source) {
72
+ if (!source) return null;
73
+ const s = source;
74
+ return s.type + (s.filePath ? ` (${s.filePath})` : s.url ? ` (${s.url})` : s.platform ? ` (${s.platform})` : '');
75
+ }
76
+
77
+ /**
78
+ * Format preferences config as a short line for display
79
+ * @param {Object} [preferences] - Preferences config
80
+ * @returns {string|null}
81
+ */
82
+ function formatPreferencesLine(preferences) {
83
+ if (!preferences || (!preferences.intent && (preferences.enableMCP === undefined || preferences.enableMCP === null) && !preferences.fieldOnboardingLevel)) {
84
+ return null;
85
+ }
86
+ const p = preferences;
87
+ const parts = [
88
+ p.fieldOnboardingLevel && `level=${p.fieldOnboardingLevel}`,
89
+ p.intent && `intent=${p.intent}`,
90
+ p.enableMCP && 'MCP',
91
+ p.enableABAC && 'ABAC',
92
+ p.enableRBAC && 'RBAC'
93
+ ].filter(Boolean);
94
+ return parts.length ? parts.join(', ') : '(defaults)';
95
+ }
96
+
97
+ /**
98
+ * Show a short summary of loaded wizard config (for resume flow)
99
+ * @param {Object} config - Loaded wizard config
100
+ * @param {string} displayPath - Path to show (e.g. integration/test/wizard.yaml)
101
+ */
102
+ function showWizardConfigSummary(config, displayPath) {
103
+ logger.log(chalk.blue('\n📋 Saved config summary'));
104
+ logger.log(chalk.gray(` From: ${displayPath}`));
105
+ if (config.appName) logger.log(chalk.gray(` App: ${config.appName}`));
106
+ if (config.mode) logger.log(chalk.gray(` Mode: ${config.mode}`));
107
+ const srcLine = formatSourceLine(config.source);
108
+ if (srcLine) logger.log(chalk.gray(` Source: ${srcLine}`));
109
+ if (config.credential) logger.log(chalk.gray(` Credential: ${config.credential.action || 'skip'}`));
110
+ const prefs = formatPreferencesLine(config.preferences);
111
+ if (prefs) logger.log(chalk.gray(` Preferences: ${prefs}`));
112
+ logger.log('');
113
+ }
114
+
115
+ /**
116
+ * Ensure integration/<appKey> directory exists
117
+ * @param {string} appKey - Application key
118
+ * @returns {Promise<string>} Resolved config path (integration/<appKey>/wizard.yaml)
119
+ */
120
+ async function ensureIntegrationDir(appKey) {
121
+ const dir = path.join(process.cwd(), 'integration', appKey);
122
+ await fs.mkdir(dir, { recursive: true });
123
+ return path.join(dir, 'wizard.yaml');
124
+ }
125
+
126
+ /** External system types that support add-datasource (excludes webapp/application) */
127
+ const EXTERNAL_SYSTEM_TYPES = ['openapi', 'mcp', 'custom'];
128
+
129
+ /**
130
+ * Returns true if the system is an external system that supports add-datasource (not a webapp).
131
+ * @param {Object} sys - System object from getExternalSystem (may have type, systemType, or kind)
132
+ * @returns {boolean}
133
+ */
134
+ function isExternalSystemForAddDatasource(sys) {
135
+ const type = (sys?.type || sys?.systemType || sys?.kind || '').toLowerCase();
136
+ if (!type) return true;
137
+ if (EXTERNAL_SYSTEM_TYPES.includes(type)) return true;
138
+ if (['webapp', 'application', 'app'].includes(type)) return false;
139
+ return true;
140
+ }
141
+
142
+ module.exports = {
143
+ buildPreferencesForSave,
144
+ buildSourceForSave,
145
+ buildWizardStateForSave,
146
+ formatSourceLine,
147
+ formatPreferencesLine,
148
+ showWizardConfigSummary,
149
+ ensureIntegrationDir,
150
+ EXTERNAL_SYSTEM_TYPES,
151
+ isExternalSystemForAddDatasource
152
+ };
@@ -13,11 +13,13 @@ const {
13
13
  promptForOpenApiFile,
14
14
  promptForOpenApiUrl,
15
15
  promptForMcpServer,
16
+ promptForCredentialAction,
16
17
  promptForKnownPlatform,
17
18
  promptForUserIntent,
18
19
  promptForUserPreferences,
19
20
  promptForConfigReview,
20
- promptForAppName
21
+ promptForAppName,
22
+ promptForRunWithSavedConfig
21
23
  } = require('../generator/wizard-prompts');
22
24
  const {
23
25
  validateAndCheckAppDirectory,
@@ -31,7 +33,17 @@ const {
31
33
  setupDataplaneAndAuth
32
34
  } = require('./wizard-core');
33
35
  const { handleWizardHeadless } = require('./wizard-headless');
34
- const { createWizardSession, updateWizardSession } = require('../api/wizard.api');
36
+ const { createWizardSession, updateWizardSession, getWizardPlatforms } = require('../api/wizard.api');
37
+ const { getExternalSystem } = require('../api/external-systems.api');
38
+ const { writeWizardConfig, wizardConfigExists, validateWizardConfig } = require('../validation/wizard-config-validator');
39
+ const { appendWizardError } = require('../utils/cli-utils');
40
+ const {
41
+ buildPreferencesForSave,
42
+ buildWizardStateForSave,
43
+ showWizardConfigSummary,
44
+ ensureIntegrationDir,
45
+ isExternalSystemForAddDatasource
46
+ } = require('./wizard-helpers');
35
47
 
36
48
  /**
37
49
  * Extract session ID from response data
@@ -53,22 +65,18 @@ function extractSessionId(responseData) {
53
65
  }
54
66
 
55
67
  /**
56
- * Handle interactive mode selection step
68
+ * Create wizard session with given mode and optional systemIdOrKey (no prompts)
57
69
  * @async
58
- * @function handleInteractiveModeSelection
70
+ * @function createSessionFromParams
59
71
  * @param {string} dataplaneUrl - Dataplane URL
60
72
  * @param {Object} authConfig - Authentication configuration
73
+ * @param {string} mode - Mode ('create-system' | 'add-datasource')
74
+ * @param {string} [systemIdOrKey] - System ID or key (for add-datasource)
61
75
  * @param {string} [appName] - Application name (for 401 hint)
62
- * @returns {Promise<Object>} Object with mode and sessionId
76
+ * @returns {Promise<string>} Session ID
63
77
  */
64
- async function handleInteractiveModeSelection(dataplaneUrl, authConfig, appName) {
65
- logger.log(chalk.blue('\n\uD83D\uDCCB Step 1: Mode Selection'));
66
- const mode = await promptForMode();
67
- let systemIdOrKey = null;
68
- if (mode === 'add-datasource') {
69
- systemIdOrKey = await promptForSystemIdOrKey();
70
- }
71
- const sessionResponse = await createWizardSession(dataplaneUrl, authConfig, mode, systemIdOrKey);
78
+ async function createSessionFromParams(dataplaneUrl, authConfig, mode, systemIdOrKey, appName) {
79
+ const sessionResponse = await createWizardSession(dataplaneUrl, authConfig, mode, systemIdOrKey || null);
72
80
  if (!sessionResponse.success || !sessionResponse.data) {
73
81
  const errorMsg = sessionResponse.formattedError || sessionResponse.error ||
74
82
  sessionResponse.errorData?.detail || sessionResponse.message ||
@@ -79,8 +87,7 @@ async function handleInteractiveModeSelection(dataplaneUrl, authConfig, appName)
79
87
  : `Failed to create wizard session: ${errorMsg}`;
80
88
  throw new Error(fullMsg);
81
89
  }
82
- const sessionId = extractSessionId(sessionResponse.data);
83
- return { mode, sessionId, systemIdOrKey };
90
+ return extractSessionId(sessionResponse.data);
84
91
  }
85
92
 
86
93
  /**
@@ -90,11 +97,12 @@ async function handleInteractiveModeSelection(dataplaneUrl, authConfig, appName)
90
97
  * @param {string} dataplaneUrl - Dataplane URL
91
98
  * @param {string} sessionId - Wizard session ID
92
99
  * @param {Object} authConfig - Authentication configuration
100
+ * @param {Array<{key: string, displayName?: string}>} [platforms] - Known platforms from dataplane (empty = hide "Known platform")
93
101
  * @returns {Promise<Object>} Object with sourceType and sourceData
94
102
  */
95
- async function handleInteractiveSourceSelection(dataplaneUrl, sessionId, authConfig) {
103
+ async function handleInteractiveSourceSelection(dataplaneUrl, sessionId, authConfig, platforms = []) {
96
104
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 2: Source Selection'));
97
- const sourceType = await promptForSourceType();
105
+ const sourceType = await promptForSourceType(platforms);
98
106
  let sourceData = null;
99
107
  const updateData = { currentStep: 1 };
100
108
 
@@ -108,7 +116,7 @@ async function handleInteractiveSourceSelection(dataplaneUrl, sessionId, authCon
108
116
  sourceData = JSON.stringify(mcpDetails);
109
117
  updateData.mcpServerUrl = mcpDetails.url || null;
110
118
  } else if (sourceType === 'known-platform') {
111
- sourceData = await promptForKnownPlatform();
119
+ sourceData = await promptForKnownPlatform(platforms);
112
120
  }
113
121
 
114
122
  const updateResponse = await updateWizardSession(dataplaneUrl, sessionId, authConfig, updateData);
@@ -131,7 +139,7 @@ async function handleInteractiveSourceSelection(dataplaneUrl, sessionId, authCon
131
139
  * @param {Object} options.detectedType - Detected type info
132
140
  * @param {string} [options.credentialIdOrKey] - Credential ID or key (optional)
133
141
  * @param {string} [options.systemIdOrKey] - System ID or key (optional)
134
- * @returns {Promise<Object>} Generated configuration
142
+ * @returns {Promise<Object>} Generated configuration and preferences { systemConfig, datasourceConfigs, systemKey, preferences }
135
143
  */
136
144
  async function handleInteractiveConfigGeneration(options) {
137
145
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 5: User Preferences'));
@@ -140,12 +148,13 @@ async function handleInteractiveConfigGeneration(options) {
140
148
 
141
149
  const configPrefs = {
142
150
  intent: userIntent,
151
+ fieldOnboardingLevel: preferences.fieldOnboardingLevel || 'full',
143
152
  enableMCP: preferences.mcp,
144
153
  enableABAC: preferences.abac,
145
154
  enableRBAC: preferences.rbac
146
155
  };
147
156
 
148
- return await handleConfigurationGeneration(options.dataplaneUrl, options.authConfig, {
157
+ const result = await handleConfigurationGeneration(options.dataplaneUrl, options.authConfig, {
149
158
  mode: options.mode,
150
159
  openapiSpec: options.openapiSpec,
151
160
  detectedType: options.detectedType,
@@ -153,6 +162,11 @@ async function handleInteractiveConfigGeneration(options) {
153
162
  credentialIdOrKey: options.credentialIdOrKey,
154
163
  systemIdOrKey: options.systemIdOrKey
155
164
  });
165
+
166
+ return {
167
+ ...result,
168
+ preferences: buildPreferencesForSave(userIntent, preferences)
169
+ };
156
170
  }
157
171
 
158
172
  /**
@@ -183,32 +197,37 @@ async function handleConfigurationReview(dataplaneUrl, authConfig, systemConfig,
183
197
  }
184
198
 
185
199
  /**
186
- * Execute wizard flow steps (interactive mode)
200
+ * Run steps 2–7 after session is created (source, credential, type, generate, review, save).
201
+ * On any error, saves partial wizard.yaml with all collected state so far, appends to error.log, then rethrows.
187
202
  * @async
188
- * @function executeWizardFlow
189
- * @param {string} appName - Application name
203
+ * @param {string} appKey - Application key
190
204
  * @param {string} dataplaneUrl - Dataplane URL
191
205
  * @param {Object} authConfig - Authentication configuration
192
- * @returns {Promise<void>} Resolves when wizard flow completes
206
+ * @param {string} sessionId - Wizard session ID
207
+ * @param {Object} flowOpts - Mode, systemIdOrKey, platforms, configPath
208
+ * @returns {Promise<Object>} Collected state (source, credential, preferences) for wizard.yaml save
193
209
  */
194
- async function executeWizardFlow(appName, dataplaneUrl, authConfig) {
195
- // Step 1: Mode Selection
196
- const { mode, sessionId, systemIdOrKey } = await handleInteractiveModeSelection(dataplaneUrl, authConfig, appName);
197
-
198
- // Step 2: Source Selection
199
- const { sourceType, sourceData } = await handleInteractiveSourceSelection(dataplaneUrl, sessionId, authConfig);
210
+ async function doWizardSteps(appKey, dataplaneUrl, authConfig, sessionId, flowOpts, state) {
211
+ const { mode, systemIdOrKey, platforms } = flowOpts;
212
+ const { sourceType, sourceData } = await handleInteractiveSourceSelection(
213
+ dataplaneUrl, sessionId, authConfig, platforms
214
+ );
215
+ state.source = { type: sourceType };
216
+ if (sourceType === 'openapi-file') state.source.filePath = sourceData;
217
+ else if (sourceType === 'openapi-url') state.source.url = sourceData;
218
+ else if (sourceType === 'mcp-server') state.source.serverUrl = JSON.parse(sourceData).serverUrl;
219
+ else if (sourceType === 'known-platform') state.source.platform = sourceData;
200
220
 
201
- // Parse OpenAPI (part of step 2)
202
221
  const openapiSpec = await handleOpenApiParsing(dataplaneUrl, authConfig, sourceType, sourceData);
222
+ const credentialAction = await promptForCredentialAction();
223
+ const configCredential = credentialAction.action === 'skip'
224
+ ? { action: 'skip' }
225
+ : { action: credentialAction.action, credentialIdOrKey: credentialAction.credentialIdOrKey };
226
+ state.credential = configCredential;
227
+ const credentialIdOrKey = await handleCredentialSelection(dataplaneUrl, authConfig, configCredential);
203
228
 
204
- // Step 3: Credential Selection (optional)
205
- const credentialIdOrKey = await handleCredentialSelection(dataplaneUrl, authConfig);
206
-
207
- // Step 4: Detect Type
208
229
  const detectedType = await handleTypeDetection(dataplaneUrl, authConfig, openapiSpec);
209
-
210
- // Step 5: Generate Configuration
211
- const { systemConfig, datasourceConfigs, systemKey } = await handleInteractiveConfigGeneration({
230
+ const genResult = await handleInteractiveConfigGeneration({
212
231
  dataplaneUrl,
213
232
  authConfig,
214
233
  mode,
@@ -217,60 +236,249 @@ async function executeWizardFlow(appName, dataplaneUrl, authConfig) {
217
236
  credentialIdOrKey,
218
237
  systemIdOrKey
219
238
  });
239
+ const { systemConfig, datasourceConfigs, systemKey, preferences: savedPrefs } = genResult;
240
+ state.preferences = savedPrefs || {};
220
241
 
221
- // Step 6-7: Review & Validate
222
242
  const finalConfigs = await handleConfigurationReview(dataplaneUrl, authConfig, systemConfig, datasourceConfigs);
223
- if (!finalConfigs) return;
243
+ if (!finalConfigs) return null;
224
244
 
225
- // Step 7: Save Files
226
245
  await handleFileSaving(
227
- appName,
246
+ appKey,
228
247
  finalConfigs.systemConfig,
229
248
  finalConfigs.datasourceConfigs,
230
- systemKey || appName,
249
+ systemKey || appKey,
231
250
  dataplaneUrl,
232
251
  authConfig
233
252
  );
253
+ return state;
254
+ }
255
+
256
+ async function runWizardStepsAfterSession(appKey, dataplaneUrl, authConfig, sessionId, flowOpts) {
257
+ const { mode, systemIdOrKey, configPath } = flowOpts;
258
+ const state = { appKey, mode, systemIdOrKey: mode === 'add-datasource' ? systemIdOrKey : undefined };
259
+
260
+ const savePartialOnError = async(err) => {
261
+ state.preferences = state.preferences || {};
262
+ if (configPath) {
263
+ try {
264
+ await writeWizardConfig(configPath, buildWizardStateForSave(state));
265
+ } catch (e) {
266
+ logger.warn(`Could not save partial wizard.yaml: ${e.message}`);
267
+ }
268
+ }
269
+ await appendWizardError(appKey, err);
270
+ err.wizardResumeMessage = `To resume: aifabrix wizard ${appKey}\nSee integration/${appKey}/error.log for details.`;
271
+ err.wizardPartialSaved = true;
272
+ };
273
+
274
+ try {
275
+ return await doWizardSteps(appKey, dataplaneUrl, authConfig, sessionId, flowOpts, state);
276
+ } catch (err) {
277
+ await savePartialOnError(err);
278
+ throw err;
279
+ }
234
280
  }
235
281
 
236
282
  /**
237
- * Handle wizard command
283
+ * Execute wizard flow steps (interactive mode). Mode and systemIdOrKey are already set; creates session then runs steps 2–7.
238
284
  * @async
239
- * @function handleWizard
285
+ * @function executeWizardFlow
286
+ * @param {string} appKey - Application/integration key (folder name under integration/)
287
+ * @param {string} dataplaneUrl - Dataplane URL
288
+ * @param {Object} authConfig - Authentication configuration
289
+ * @param {Object} flowOpts - Flow options (mode, systemIdOrKey, configPath)
290
+ * @returns {Promise<void>} Resolves when wizard flow completes
291
+ */
292
+ async function executeWizardFlow(appKey, dataplaneUrl, authConfig, flowOpts = {}) {
293
+ const { mode, systemIdOrKey, configPath } = flowOpts;
294
+
295
+ logger.log(chalk.blue('\n\uD83D\uDCCB Step 1: Create Session'));
296
+ const sessionId = await createSessionFromParams(dataplaneUrl, authConfig, mode, systemIdOrKey, appKey);
297
+ logger.log(chalk.green('\u2713 Session created'));
298
+
299
+ const platforms = await getWizardPlatforms(dataplaneUrl, authConfig);
300
+ const state = await runWizardStepsAfterSession(appKey, dataplaneUrl, authConfig, sessionId, {
301
+ mode,
302
+ systemIdOrKey,
303
+ platforms,
304
+ configPath
305
+ });
306
+ if (!state) return;
307
+
308
+ if (configPath) {
309
+ await writeWizardConfig(configPath, buildWizardStateForSave(state));
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Load wizard config from configPath if it exists (for prefill)
315
+ * @param {string} [configPath] - Path to wizard.yaml (e.g. integration/<app>/wizard.yaml)
316
+ * @param {string} [appName] - App name (for log message)
317
+ * @returns {Promise<Object|null>} Loaded config or null
318
+ */
319
+ async function loadWizardConfigIfExists(configPath, appName) {
320
+ if (!configPath) return null;
321
+ const displayPath = appName ? `integration/${appName}/wizard.yaml` : configPath;
322
+ try {
323
+ const exists = await wizardConfigExists(configPath);
324
+ if (!exists) {
325
+ logger.log(chalk.gray(`No saved state at ${displayPath}; starting from step 1.`));
326
+ return null;
327
+ }
328
+ const result = await validateWizardConfig(configPath, { validateFilePaths: false });
329
+ if (result.valid && result.config) {
330
+ logger.log(chalk.green(`Loaded saved state from ${displayPath}. Resuming with saved choices.`));
331
+ return result.config;
332
+ }
333
+ if (result.errors?.length) {
334
+ logger.log(chalk.yellow(`Loaded ${displayPath} but it has errors; prompting for missing fields.`));
335
+ }
336
+ } catch (e) {
337
+ logger.log(chalk.gray(`Could not load wizard config from ${displayPath}: ${e.message}`));
338
+ }
339
+ return null;
340
+ }
341
+
342
+ /**
343
+ * Resolve appKey, configPath, dataplane and auth for "create-system" mode
240
344
  * @param {Object} options - Command options
241
- * @param {string} [options.app] - Application name
242
- * @param {string} [options.controller] - Controller URL
243
- * @param {string} [options.environment] - Environment key
244
- * @param {string} [options.dataplane] - Dataplane URL (overrides controller lookup)
245
- * @param {string} [options.config] - Path to wizard.yaml config file (headless mode)
246
- * @returns {Promise<void>} Resolves when wizard completes
247
- * @throws {Error} If wizard fails
345
+ * @param {Object} [loadedConfig] - Loaded wizard config
346
+ * @returns {Promise<Object|null>} { appKey, configPath, dataplaneUrl, authConfig } or null if cancelled
248
347
  */
249
- async function handleWizard(options = {}) {
250
- // Check if headless mode (config file provided)
251
- if (options.config) {
252
- return await handleWizardHeadless(options);
348
+ async function resolveCreateNewPath(options, loadedConfig) {
349
+ const appName = options.app || loadedConfig?.appName || (await promptForAppName(loadedConfig?.appName));
350
+ const shouldContinue = await validateAndCheckAppDirectory(appName, true);
351
+ if (!shouldContinue) return null;
352
+ const appKey = appName;
353
+ const configPath = await ensureIntegrationDir(appKey);
354
+ const { dataplaneUrl, authConfig } = await setupDataplaneAndAuth(options, appName);
355
+ return { appKey, configPath, dataplaneUrl, authConfig };
356
+ }
357
+
358
+ /**
359
+ * Resolve appKey, configPath, dataplane and auth for "add-datasource" mode (validates system)
360
+ * @param {Object} options - Command options
361
+ * @param {Object} [loadedConfig] - Loaded wizard config
362
+ * @returns {Promise<Object>} { appKey, configPath, dataplaneUrl, authConfig, systemIdOrKey }
363
+ */
364
+ async function resolveAddDatasourcePath(options, loadedConfig) {
365
+ let systemIdOrKey = loadedConfig?.systemIdOrKey || (await promptForSystemIdOrKey(loadedConfig?.systemIdOrKey));
366
+ const { dataplaneUrl, authConfig } = await setupDataplaneAndAuth(options, systemIdOrKey || 'wizard');
367
+ let systemResponse;
368
+ for (;;) {
369
+ try {
370
+ systemResponse = await getExternalSystem(dataplaneUrl, systemIdOrKey, authConfig);
371
+ const sys = systemResponse?.data || systemResponse;
372
+ if (sys && (systemResponse?.data || systemResponse?.success)) {
373
+ if (!isExternalSystemForAddDatasource(sys)) {
374
+ logger.log(chalk.red('Cannot add datasource to a webapp. Please enter an external system ID or key.'));
375
+ systemIdOrKey = await promptForSystemIdOrKey(systemIdOrKey);
376
+ continue;
377
+ }
378
+ break;
379
+ }
380
+ } catch (err) {
381
+ logger.log(chalk.red(`System not found or error: ${err.message}`));
382
+ }
383
+ systemIdOrKey = await promptForSystemIdOrKey(systemIdOrKey);
253
384
  }
385
+ const sys = systemResponse?.data || systemResponse;
386
+ const appKey = sys?.key || sys?.systemKey || systemIdOrKey;
387
+ const configPath = await ensureIntegrationDir(appKey);
388
+ return { appKey, configPath, dataplaneUrl, authConfig, systemIdOrKey };
389
+ }
254
390
 
255
- logger.log(chalk.blue('\n\uD83E\uDDD9 AI Fabrix External System Wizard\n'));
391
+ /**
392
+ * On wizard error: append to error.log, save partial wizard.yaml, set resume message
393
+ * @param {string} appKey - Application key
394
+ * @param {string} [configPath] - Path to wizard.yaml
395
+ * @param {string} mode - Wizard mode
396
+ * @param {string} [systemIdOrKey] - System ID (add-datasource)
397
+ * @param {Error} error - The error
398
+ */
399
+ async function handleWizardError(appKey, configPath, mode, systemIdOrKey, error) {
400
+ await appendWizardError(appKey, error);
401
+ if (!error.wizardPartialSaved && configPath) {
402
+ const partial = buildWizardStateForSave({
403
+ appKey,
404
+ mode,
405
+ systemIdOrKey: mode === 'add-datasource' ? systemIdOrKey : undefined
406
+ });
407
+ try {
408
+ await writeWizardConfig(configPath, partial);
409
+ } catch (e) {
410
+ logger.warn(`Could not save partial wizard.yaml: ${e.message}`);
411
+ }
412
+ }
413
+ error.wizardResumeMessage = `To resume: aifabrix wizard ${appKey}\nSee integration/${appKey}/error.log for details.`;
414
+ }
256
415
 
257
- // Get or prompt for app name
258
- let appName = options.app;
259
- if (!appName) {
260
- appName = await promptForAppName();
416
+ /**
417
+ * Handle wizard command (mode-first, load/save wizard.yaml, error.log on failure)
418
+ * @async
419
+ * @function handleWizard
420
+ * @param {Object} options - Command options
421
+ * @param {string} [options.app] - Application name (from positional or -a)
422
+ * @param {string} [options.config] - Path to wizard.yaml (headless mode)
423
+ * @param {string} [options.configPath] - Resolved path integration/<app>/wizard.yaml for load/save
424
+ * @returns {Promise<void>} Resolves when wizard completes
425
+ * @throws {Error} If wizard fails (wizardResumeMessage set when appKey known)
426
+ */
427
+ async function handleWizardSilent(options) {
428
+ if (!options.configPath) {
429
+ throw new Error('--silent requires an app name (e.g. aifabrix wizard test --silent)');
430
+ }
431
+ const result = await validateWizardConfig(options.configPath, { validateFilePaths: false });
432
+ if (!result.valid || !result.config) {
433
+ const displayPath = options.app ? `integration/${options.app}/wizard.yaml` : '';
434
+ const errMsg = result.errors?.length ? result.errors.join('; ') : 'Invalid or missing wizard.yaml';
435
+ throw new Error(`Cannot run --silent: ${displayPath} is invalid or missing. ${errMsg}`);
261
436
  }
437
+ return await handleWizardHeadless({ ...options, config: options.configPath });
438
+ }
262
439
 
263
- // Validate app name and check directory
264
- const shouldContinue = await validateAndCheckAppDirectory(appName, true);
265
- if (!shouldContinue) {
440
+ async function handleWizardWithSavedConfig(options, loadedConfig, displayPath) {
441
+ showWizardConfigSummary(loadedConfig, displayPath);
442
+ const runWithSaved = await promptForRunWithSavedConfig();
443
+ if (!runWithSaved) {
444
+ logger.log(chalk.gray(`To change settings, edit ${displayPath} and run: aifabrix wizard ${options.app}`));
266
445
  return;
267
446
  }
447
+ logger.log(chalk.gray(`Running with saved config from ${displayPath}...\n`));
448
+ return await handleWizardHeadless({ ...options, config: options.configPath });
449
+ }
268
450
 
269
- // Get dataplane URL and authentication
270
- const { dataplaneUrl, authConfig } = await setupDataplaneAndAuth(options, appName);
451
+ async function handleWizardInteractive(options) {
452
+ const mode = await promptForMode();
453
+ const resolved = mode === 'create-system'
454
+ ? await resolveCreateNewPath(options, null)
455
+ : await resolveAddDatasourcePath(options, null);
456
+ if (!resolved) return;
457
+ const { appKey, configPath, dataplaneUrl, authConfig } = resolved;
458
+ const systemIdOrKey = mode === 'add-datasource' ? resolved.systemIdOrKey : undefined;
459
+ try {
460
+ await executeWizardFlow(appKey, dataplaneUrl, authConfig, { mode, systemIdOrKey, configPath });
461
+ logger.log(chalk.gray(`To change settings, edit integration/${appKey}/wizard.yaml and run: aifabrix wizard ${appKey}`));
462
+ } catch (error) {
463
+ await handleWizardError(appKey, configPath, mode, systemIdOrKey, error);
464
+ throw error;
465
+ }
466
+ }
271
467
 
272
- // Execute wizard flow
273
- await executeWizardFlow(appName, dataplaneUrl, authConfig);
468
+ async function handleWizard(options = {}) {
469
+ if (options.config) {
470
+ return await handleWizardHeadless(options);
471
+ }
472
+ const displayPath = options.app ? `integration/${options.app}/wizard.yaml` : '';
473
+ if (options.silent && options.app) {
474
+ return await handleWizardSilent(options);
475
+ }
476
+ logger.log(chalk.blue('\n\uD83E\uDDD9 AI Fabrix External System Wizard\n'));
477
+ const loadedConfig = await loadWizardConfigIfExists(options.configPath, options.app);
478
+ if (loadedConfig) {
479
+ return await handleWizardWithSavedConfig(options, loadedConfig, displayPath);
480
+ }
481
+ return await handleWizardInteractive(options);
274
482
  }
275
483
 
276
484
  module.exports = { handleWizard, handleWizardHeadless };
@@ -91,6 +91,37 @@ function buildAndValidateDeployment(appName, variables, envTemplate, rbac) {
91
91
  return deployment;
92
92
  }
93
93
 
94
+ /**
95
+ * Builds deployment manifest in memory (no file write, no schema validation).
96
+ * Same structure as generateDeployJson output; used by "aifabrix show" offline.
97
+ * @async
98
+ * @function buildDeploymentManifestInMemory
99
+ * @param {string} appName - Application name
100
+ * @param {Object} [options] - Options (e.g. type for external)
101
+ * @returns {Promise<{ deployment: Object, appPath: string }>} Manifest and app path
102
+ * @throws {Error} If variables.yaml/env.template missing or generation fails
103
+ */
104
+ async function buildDeploymentManifestInMemory(appName, options = {}) {
105
+ if (!appName || typeof appName !== 'string') {
106
+ throw new Error('App name is required and must be a string');
107
+ }
108
+
109
+ const { isExternal, appPath, appType } = await detectAppType(appName, options);
110
+
111
+ if (isExternal) {
112
+ const manifest = await generateControllerManifest(appName);
113
+ return { deployment: manifest, appPath };
114
+ }
115
+
116
+ const { variables, envTemplate, rbac } = loadDeploymentConfigFiles(appPath, appType, appName);
117
+ const configuration = parseEnvironmentVariables(envTemplate, variables);
118
+ const deployment = builders.buildManifestStructure(appName, variables, null, configuration, rbac);
119
+ const deploymentKey = _keyGenerator.generateDeploymentKeyFromJson(deployment);
120
+ deployment.deploymentKey = deploymentKey;
121
+
122
+ return { deployment, appPath };
123
+ }
124
+
94
125
  async function generateDeployJson(appName, options = {}) {
95
126
  if (!appName || typeof appName !== 'string') {
96
127
  throw new Error('App name is required and must be a string');
@@ -153,6 +184,7 @@ async function generateDeployJsonWithValidation(appName, options = {}) {
153
184
  module.exports = {
154
185
  generateDeployJson,
155
186
  generateDeployJsonWithValidation,
187
+ buildDeploymentManifestInMemory,
156
188
  generateExternalSystemApplicationSchema,
157
189
  splitExternalApplicationSchema,
158
190
  parseEnvironmentVariables,