@aifabrix/builder 2.44.4 → 2.44.5

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.
@@ -26,6 +26,7 @@ const {
26
26
  validateAndCheckAppDirectory,
27
27
  formatDataplaneRejectedTokenMessage,
28
28
  extractSessionId,
29
+ handleSourceSelection,
29
30
  handleOpenApiParsing,
30
31
  handleCredentialSelection,
31
32
  handleTypeDetection,
@@ -50,6 +51,149 @@ const {
50
51
  } = require('./wizard-helpers');
51
52
  const { humanizeAppKey } = require('../generator/wizard-prompts-secondary');
52
53
 
54
+ /**
55
+ * Map resolved source type/data onto wizard state.source.
56
+ * @param {Object} state - Mutable wizard state
57
+ * @param {string} sourceType - Source type key
58
+ * @param {unknown} sourceData - Raw source payload from prompts
59
+ */
60
+ function applySourceSelectionToState(state, sourceType, sourceData) {
61
+ state.source = { type: sourceType };
62
+ if (sourceType === 'openapi-file') state.source.filePath = sourceData;
63
+ else if (sourceType === 'openapi-url') state.source.url = sourceData;
64
+ else if (sourceType === 'mcp-server') state.source.serverUrl = JSON.parse(sourceData).serverUrl;
65
+ else if (sourceType === 'known-platform') state.source.platform = sourceData;
66
+ }
67
+
68
+ /**
69
+ * Interactive or prefill source selection (Step 2).
70
+ * @returns {Promise<{ sourceType: string, sourceData: unknown }>}
71
+ */
72
+ async function resolveWizardSourcePhase(dataplaneUrl, sessionId, authConfig, platforms, prefill) {
73
+ if (prefill?.source?.type) {
74
+ logger.log(chalk.gray(
75
+ `Using source from wizard.yaml (${prefill.source.type}). Skipping source prompts.`
76
+ ));
77
+ return handleSourceSelection(dataplaneUrl, sessionId, authConfig, prefill.source);
78
+ }
79
+ return handleInteractiveSourceSelection(dataplaneUrl, sessionId, authConfig, platforms);
80
+ }
81
+
82
+ /**
83
+ * Credential selection with optional prefill (Step 3).
84
+ * @returns {Promise<string>} credentialIdOrKey for generation step
85
+ */
86
+ async function resolveWizardCredentialPhase(dataplaneUrl, authConfig, prefill, state) {
87
+ if (prefill?.credential) {
88
+ state.credential = prefill.credential;
89
+ return handleCredentialSelection(dataplaneUrl, authConfig, prefill.credential);
90
+ }
91
+ const credentialAction = await promptForCredentialAction();
92
+ const configCredential = await resolveCredentialConfig(dataplaneUrl, authConfig, credentialAction);
93
+ state.credential = configCredential;
94
+ return handleCredentialSelection(dataplaneUrl, authConfig, configCredential);
95
+ }
96
+
97
+ /**
98
+ * Collect user intent and UI preferences (Step 5), with optional wizard.yaml prefill.
99
+ * @returns {Promise<{ userIntent: string, preferences: Object, hasPrefillIntent: boolean }>}
100
+ */
101
+ async function collectIntentAndPreferences(preferencesPrefill) {
102
+ const prefillPrefs = preferencesPrefill;
103
+ const hasPrefillIntent =
104
+ prefillPrefs &&
105
+ typeof prefillPrefs.intent === 'string' &&
106
+ prefillPrefs.intent.trim().length > 0;
107
+
108
+ if (hasPrefillIntent) {
109
+ logger.log(chalk.gray(
110
+ 'Using preferences from wizard.yaml (intent and toggles). Skipping preference prompts.'
111
+ ));
112
+ const level = prefillPrefs.fieldOnboardingLevel;
113
+ const validLevel = level === 'standard' || level === 'minimal' ? level : 'full';
114
+ return {
115
+ userIntent: prefillPrefs.intent.trim(),
116
+ preferences: {
117
+ fieldOnboardingLevel: validLevel,
118
+ mcp: Boolean(prefillPrefs.enableMCP),
119
+ abac: Boolean(prefillPrefs.enableABAC),
120
+ rbac: Boolean(prefillPrefs.enableRBAC)
121
+ },
122
+ hasPrefillIntent: true
123
+ };
124
+ }
125
+
126
+ const userIntent = await promptForUserIntent();
127
+ const preferences = await promptForUserPreferences();
128
+ return { userIntent, preferences, hasPrefillIntent: false };
129
+ }
130
+
131
+ /**
132
+ * Run Step 5 (generate), Step 6–7 (review), and save files; updates state.preferences.
133
+ * @param {Object} payload
134
+ * @param {string} payload.appKey - Application key
135
+ * @param {string} payload.dataplaneUrl - Dataplane URL
136
+ * @param {Object} payload.authConfig - Auth configuration
137
+ * @param {string} payload.sessionId - Wizard session ID
138
+ * @param {Object} payload.state - Mutable wizard state
139
+ * @param {Object} payload.flowOpts - Flow options (mode, systemIdOrKey, debug, prefill, etc.)
140
+ * @param {Object} payload.genInput - Generation inputs (openapiSpec, detectedType, credential…)
141
+ * @returns {Promise<Object|null>} Updated state or null if review cancelled
142
+ */
143
+ async function completeWizardGenerateReviewSave(payload) {
144
+ const {
145
+ appKey,
146
+ dataplaneUrl,
147
+ authConfig,
148
+ sessionId,
149
+ state,
150
+ flowOpts,
151
+ genInput
152
+ } = payload;
153
+ const { mode, systemIdOrKey, debug, prefill } = flowOpts;
154
+ const genResult = await handleInteractiveConfigGeneration({
155
+ dataplaneUrl,
156
+ authConfig,
157
+ mode,
158
+ openapiSpec: genInput.openapiSpec,
159
+ detectedType: genInput.detectedType,
160
+ credentialIdOrKey: genInput.credentialIdOrKey,
161
+ systemIdOrKey,
162
+ sourceType: genInput.sourceType,
163
+ sourceData: genInput.sourceData,
164
+ entityName: genInput.entityName,
165
+ appName: appKey,
166
+ debug,
167
+ systemDisplayName: flowOpts.systemDisplayName,
168
+ preferencesPrefill: prefill?.preferences
169
+ });
170
+ const { systemConfig, datasourceConfigs, systemKey, preferences: savedPrefs } = genResult;
171
+ state.preferences = savedPrefs || {};
172
+
173
+ const finalConfigs = await handleConfigurationReview(
174
+ dataplaneUrl,
175
+ authConfig,
176
+ sessionId,
177
+ systemConfig,
178
+ datasourceConfigs,
179
+ { appKey, debug }
180
+ );
181
+ if (!finalConfigs) return null;
182
+
183
+ await handleFileSaving(
184
+ appKey,
185
+ finalConfigs.systemConfig,
186
+ finalConfigs.datasourceConfigs,
187
+ systemKey || appKey,
188
+ {
189
+ dataplaneUrl,
190
+ authConfig,
191
+ enableRBAC: Boolean(savedPrefs?.enableRBAC)
192
+ }
193
+ );
194
+ return state;
195
+ }
196
+
53
197
  /**
54
198
  * Create wizard session with given mode and optional systemIdOrKey (no prompts)
55
199
  * @async
@@ -125,12 +269,15 @@ async function handleInteractiveSourceSelection(dataplaneUrl, sessionId, authCon
125
269
  * @param {Object} options.detectedType - Detected type info
126
270
  * @param {string} [options.credentialIdOrKey] - Credential ID or key (optional)
127
271
  * @param {string} [options.systemIdOrKey] - System ID or key (optional)
272
+ * @param {Object} [options.preferencesPrefill] - From wizard.yaml `preferences` (skip Step 5 prompts when intent is set)
128
273
  * @returns {Promise<Object>} Generated configuration and preferences { systemConfig, datasourceConfigs, systemKey, preferences }
129
274
  */
130
275
  async function handleInteractiveConfigGeneration(options) {
131
276
  logger.log(chalk.blue('\n\uD83D\uDCCB Step 5: User Preferences'));
132
- const userIntent = await promptForUserIntent();
133
- const preferences = await promptForUserPreferences();
277
+
278
+ const { userIntent, preferences, hasPrefillIntent } = await collectIntentAndPreferences(
279
+ options.preferencesPrefill
280
+ );
134
281
 
135
282
  const configPrefs = {
136
283
  intent: userIntent,
@@ -138,6 +285,9 @@ async function handleInteractiveConfigGeneration(options) {
138
285
  enableMCP: preferences.mcp,
139
286
  enableABAC: preferences.abac,
140
287
  enableRBAC: preferences.rbac,
288
+ enableOpenAPIGeneration: hasPrefillIntent
289
+ ? options.preferencesPrefill?.enableOpenAPIGeneration !== false
290
+ : true,
141
291
  debug: options.debug === true
142
292
  };
143
293
 
@@ -222,62 +372,43 @@ async function handleConfigurationReview(dataplaneUrl, authConfig, sessionId, sy
222
372
  * @returns {Promise<Object>} Collected state (source, credential, preferences) for wizard.yaml save
223
373
  */
224
374
  async function doWizardSteps(appKey, dataplaneUrl, authConfig, sessionId, flowOpts, state) {
225
- const { mode, systemIdOrKey, platforms, debug } = flowOpts;
226
- const { sourceType, sourceData } = await handleInteractiveSourceSelection(
227
- dataplaneUrl, sessionId, authConfig, platforms
375
+ const { platforms, prefill } = flowOpts;
376
+
377
+ const { sourceType, sourceData } = await resolveWizardSourcePhase(
378
+ dataplaneUrl,
379
+ sessionId,
380
+ authConfig,
381
+ platforms,
382
+ prefill
228
383
  );
229
- state.source = { type: sourceType };
230
- if (sourceType === 'openapi-file') state.source.filePath = sourceData;
231
- else if (sourceType === 'openapi-url') state.source.url = sourceData;
232
- else if (sourceType === 'mcp-server') state.source.serverUrl = JSON.parse(sourceData).serverUrl;
233
- else if (sourceType === 'known-platform') state.source.platform = sourceData;
384
+ applySourceSelectionToState(state, sourceType, sourceData);
234
385
 
235
386
  const openapiSpec = await handleOpenApiParsing(dataplaneUrl, authConfig, sourceType, sourceData);
236
- const credentialAction = await promptForCredentialAction();
237
- const configCredential = await resolveCredentialConfig(dataplaneUrl, authConfig, credentialAction);
238
- state.credential = configCredential;
239
- const credentialIdOrKey = await handleCredentialSelection(dataplaneUrl, authConfig, configCredential);
387
+ const credentialIdOrKey = await resolveWizardCredentialPhase(dataplaneUrl, authConfig, prefill, state);
240
388
 
241
389
  const detectedType = await handleTypeDetection(dataplaneUrl, authConfig, openapiSpec);
390
+ const prefillEntityName = prefill?.source?.entityName;
242
391
  const entityName = openapiSpec && sourceType !== 'known-platform'
243
- ? await handleEntitySelection(dataplaneUrl, authConfig, openapiSpec) : null;
244
- const genResult = await handleInteractiveConfigGeneration({
245
- dataplaneUrl,
246
- authConfig,
247
- mode,
248
- openapiSpec,
249
- detectedType,
250
- credentialIdOrKey,
251
- systemIdOrKey,
252
- sourceType,
253
- sourceData,
254
- entityName: entityName || undefined,
255
- appName: appKey,
256
- debug,
257
- systemDisplayName: flowOpts.systemDisplayName
258
- });
259
- const { systemConfig, datasourceConfigs, systemKey, preferences: savedPrefs } = genResult;
260
- state.preferences = savedPrefs || {};
261
-
262
- const finalConfigs = await handleConfigurationReview(
392
+ ? await handleEntitySelection(dataplaneUrl, authConfig, openapiSpec, prefillEntityName) : null;
393
+ if (entityName && state.source) {
394
+ state.source.entityName = entityName;
395
+ }
396
+ return completeWizardGenerateReviewSave({
397
+ appKey,
263
398
  dataplaneUrl,
264
399
  authConfig,
265
400
  sessionId,
266
- systemConfig,
267
- datasourceConfigs,
268
- { appKey, debug }
269
- );
270
- if (!finalConfigs) return null;
271
-
272
- await handleFileSaving(
273
- appKey,
274
- finalConfigs.systemConfig,
275
- finalConfigs.datasourceConfigs,
276
- systemKey || appKey,
277
- dataplaneUrl,
278
- authConfig
279
- );
280
- return state;
401
+ state,
402
+ flowOpts,
403
+ genInput: {
404
+ openapiSpec,
405
+ detectedType,
406
+ credentialIdOrKey,
407
+ sourceType,
408
+ sourceData,
409
+ entityName: entityName || undefined
410
+ }
411
+ });
281
412
  }
282
413
 
283
414
  async function runWizardStepsAfterSession(appKey, dataplaneUrl, authConfig, sessionId, flowOpts) {
@@ -317,7 +448,7 @@ async function runWizardStepsAfterSession(appKey, dataplaneUrl, authConfig, sess
317
448
  * @returns {Promise<void>} Resolves when wizard flow completes
318
449
  */
319
450
  async function executeWizardFlow(appKey, dataplaneUrl, authConfig, flowOpts = {}) {
320
- const { mode, systemIdOrKey, configPath, debug, systemDisplayName } = flowOpts;
451
+ const { mode, systemIdOrKey, configPath, debug, systemDisplayName, prefill } = flowOpts;
321
452
 
322
453
  if (debug) {
323
454
  logger.log(chalk.gray(`[DEBUG] Wizard debug mode enabled for app: ${appKey}`));
@@ -333,7 +464,8 @@ async function executeWizardFlow(appKey, dataplaneUrl, authConfig, flowOpts = {}
333
464
  platforms,
334
465
  configPath,
335
466
  debug: flowOpts.debug,
336
- systemDisplayName
467
+ systemDisplayName,
468
+ prefill
337
469
  });
338
470
  if (!state) return;
339
471
 
@@ -348,6 +480,11 @@ async function executeWizardFlow(appKey, dataplaneUrl, authConfig, flowOpts = {}
348
480
  * @param {string} [appName] - App name (for log message)
349
481
  * @returns {Promise<Object|null>} Loaded config or null
350
482
  */
483
+ /**
484
+ * Load wizard.yaml when present. Returns full valid config, or invalid-but-parsed config for interactive prefill.
485
+ *
486
+ * @returns {Promise<{ valid: true, config: Object } | { valid: false, config: Object, errors: string[] } | null>}
487
+ */
351
488
  async function loadWizardConfigIfExists(configPath, appName) {
352
489
  if (!configPath) return null;
353
490
  const displayPath = appName ? `integration/${appName}/wizard.yaml` : configPath;
@@ -360,10 +497,16 @@ async function loadWizardConfigIfExists(configPath, appName) {
360
497
  const result = await validateWizardConfig(configPath, { validateFilePaths: false });
361
498
  if (result.valid && result.config) {
362
499
  logger.log(chalk.green(`Loaded saved state from ${displayPath}. Resuming with saved choices.`));
363
- return result.config;
500
+ return { valid: true, config: result.config };
364
501
  }
365
502
  if (result.errors?.length) {
366
- logger.log(chalk.yellow(`Loaded ${displayPath} but it has errors; prompting for missing fields.`));
503
+ logger.log(chalk.yellow(
504
+ `Loaded ${displayPath} but it does not fully validate; prefilling from file where possible.`
505
+ ));
506
+ result.errors.forEach(err => logger.log(chalk.gray(` • ${err}`)));
507
+ }
508
+ if (result.config) {
509
+ return { valid: false, config: result.config, errors: result.errors || [] };
367
510
  }
368
511
  } catch (e) {
369
512
  logger.log(chalk.gray(`Could not load wizard config from ${displayPath}: ${e.message}`));
@@ -470,15 +613,16 @@ async function handleWizardWithSavedConfig(options, loadedConfig, displayPath) {
470
613
  }
471
614
 
472
615
  async function handleWizardInteractive(options) {
616
+ const prefill = options.wizardPrefill;
473
617
  const allowAddDatasource = !options.app;
474
618
  const mode = allowAddDatasource ? await promptForMode(undefined, true) : 'create-system';
475
619
  const resolved = mode === 'create-system'
476
- ? await resolveCreateNewPath(options, null)
477
- : await resolveAddDatasourcePath(options, null);
620
+ ? await resolveCreateNewPath(options, prefill || null)
621
+ : await resolveAddDatasourcePath(options, prefill || null);
478
622
  if (!resolved) return;
479
623
  const { appKey, configPath, dataplaneUrl, authConfig } = resolved;
480
624
  const systemIdOrKey = mode === 'add-datasource' ? resolved.systemIdOrKey : undefined;
481
- const systemDisplayName = options.systemDisplayName || options.displayName ||
625
+ const systemDisplayName = options.systemDisplayName || options.displayName || prefill?.systemDisplayName ||
482
626
  (mode === 'create-system' ? humanizeAppKey(appKey) : undefined);
483
627
  try {
484
628
  await executeWizardFlow(appKey, dataplaneUrl, authConfig, {
@@ -486,7 +630,8 @@ async function handleWizardInteractive(options) {
486
630
  systemIdOrKey,
487
631
  configPath,
488
632
  debug: options.debug,
489
- systemDisplayName
633
+ systemDisplayName,
634
+ prefill: prefill || undefined
490
635
  });
491
636
  logger.log(chalk.gray(`To change settings, edit integration/${appKey}/wizard.yaml and run: aifabrix wizard ${appKey}`));
492
637
  } catch (error) {
@@ -504,9 +649,12 @@ async function handleWizard(options = {}) {
504
649
  return await handleWizardSilent(options);
505
650
  }
506
651
  logger.log(chalk.blue('\n\uD83E\uDDD9 AI Fabrix External System Wizard\n'));
507
- const loadedConfig = await loadWizardConfigIfExists(options.configPath, options.app);
508
- if (loadedConfig) {
509
- return await handleWizardWithSavedConfig(options, loadedConfig, displayPath);
652
+ const loadResult = await loadWizardConfigIfExists(options.configPath, options.app);
653
+ if (loadResult?.valid && loadResult.config) {
654
+ return await handleWizardWithSavedConfig(options, loadResult.config, displayPath);
655
+ }
656
+ if (loadResult?.config && loadResult.valid === false) {
657
+ return await handleWizardInteractive({ ...options, wizardPrefill: loadResult.config });
510
658
  }
511
659
  return await handleWizardInteractive(options);
512
660
  }
@@ -324,17 +324,22 @@ async function promptForExistingCredentialInput() {
324
324
  * @function promptForUserIntent
325
325
  * @returns {Promise<string>} User intent
326
326
  */
327
+ const WIZARD_INTENT_MAX_LENGTH = 1000;
328
+
327
329
  async function promptForUserIntent() {
328
330
  const { intent } = await inquirer.prompt([
329
331
  {
330
332
  type: 'input',
331
333
  name: 'intent',
332
- message: 'Describe your primary use case (any text):',
334
+ message: `Describe your primary use case (max ${WIZARD_INTENT_MAX_LENGTH} characters):`,
333
335
  default: 'general integration',
334
336
  validate: (input) => {
335
337
  if (!input || typeof input !== 'string' || input.trim().length === 0) {
336
338
  return 'Intent is required';
337
339
  }
340
+ if (input.length > WIZARD_INTENT_MAX_LENGTH) {
341
+ return `Intent must be ${WIZARD_INTENT_MAX_LENGTH} characters or fewer`;
342
+ }
338
343
  return true;
339
344
  }
340
345
  }
@@ -437,6 +442,7 @@ async function promptForRunWithSavedConfig() {
437
442
  const secondary = require('./wizard-prompts-secondary');
438
443
 
439
444
  module.exports = {
445
+ WIZARD_INTENT_MAX_LENGTH,
440
446
  promptForMode,
441
447
  promptForSystemIdOrKey,
442
448
  promptForExistingSystem,
@@ -32,6 +32,35 @@ function toKeySegment(str) {
32
32
  return sanitized || 'default';
33
33
  }
34
34
 
35
+ /**
36
+ * Align authentication.security kv:// namespaces and credentialKey with the final system key.
37
+ * Dataplane normalizes this before respond; when appName overrides a spec-derived key (e.g.
38
+ * OpenAPI title "Companies"), the builder still must rewrite nested auth so it matches env.template.
39
+ *
40
+ * @param {Object|null|undefined} authentication - authentication block from dataplane
41
+ * @param {string} systemKey - Final external system key (integration app name)
42
+ * @param {string} [authDisplayName] - Credential display name (typically title-cased app name)
43
+ */
44
+ function normalizeAuthenticationToSystemKey(authentication, systemKey, authDisplayName) {
45
+ if (!authentication || typeof authentication !== 'object') return;
46
+ authentication.credentialKey = `${systemKey}-cred`;
47
+ const security = authentication.security;
48
+ if (security && typeof security === 'object') {
49
+ for (const k of Object.keys(security)) {
50
+ const v = security[k];
51
+ if (typeof v === 'string' && v.startsWith('kv://')) {
52
+ const rest = v.slice(5);
53
+ const idx = rest.indexOf('/');
54
+ const suffix = idx >= 0 ? rest.slice(idx + 1) : '';
55
+ security[k] = suffix ? `kv://${systemKey}/${suffix}` : `kv://${systemKey}`;
56
+ }
57
+ }
58
+ }
59
+ if (authDisplayName) {
60
+ authentication.displayName = authDisplayName;
61
+ }
62
+ }
63
+
35
64
  /**
36
65
  * Generate files from dataplane-generated wizard configurations
37
66
  * @async
@@ -182,6 +211,11 @@ async function prepareWizardContext(appName, systemConfig, datasourceConfigs) {
182
211
  const originalSystemKey = systemConfig.key || finalSystemKey;
183
212
  const appDisplayName = appName.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
184
213
  const updatedSystemConfig = { ...systemConfig, key: finalSystemKey, displayName: appDisplayName };
214
+ normalizeAuthenticationToSystemKey(
215
+ updatedSystemConfig.authentication,
216
+ finalSystemKey,
217
+ appDisplayName
218
+ );
185
219
  const originalPrefix = `${originalSystemKey}-`;
186
220
  const updatedDatasourceConfigs = datasourceConfigs.map(ds => {
187
221
  let newKey;
@@ -184,7 +184,7 @@
184
184
  "type": "string",
185
185
  "description": "User intent (any descriptive text, e.g., 'sales-focused CRM integration')",
186
186
  "minLength": 1,
187
- "maxLength": 500
187
+ "maxLength": 1000
188
188
  },
189
189
  "fieldOnboardingLevel": {
190
190
  "type": "string",
@@ -137,11 +137,43 @@ function rbacOptionalFilename(normalizedExt) {
137
137
  return normalizedExt === '.yaml' || normalizedExt === '.yml' ? 'rbac.yaml' : 'rbac.json';
138
138
  }
139
139
 
140
+ /**
141
+ * Word-wraps plain description text for Markdown (MD013 ~80 columns). Keeps blank
142
+ * lines between paragraphs.
143
+ * @param {string} text - Raw description
144
+ * @param {number} [maxLen=80] - Target max line length
145
+ * @returns {string}
146
+ */
147
+ function wrapPlainTextForMarkdown(text, maxLen = 80) {
148
+ if (!text || typeof text !== 'string') return text;
149
+ const blocks = text.split(/\n\s*\n/);
150
+ const wrapped = blocks.map((block) => {
151
+ const flat = block.replace(/\s+/g, ' ').trim();
152
+ if (!flat) return '';
153
+ const words = flat.split(' ');
154
+ const lines = [];
155
+ let current = '';
156
+ for (const w of words) {
157
+ const candidate = current ? `${current} ${w}` : w;
158
+ if (candidate.length <= maxLen) {
159
+ current = candidate;
160
+ } else {
161
+ if (current) lines.push(current);
162
+ current = w;
163
+ }
164
+ }
165
+ if (current) lines.push(current);
166
+ return lines.join('\n');
167
+ });
168
+ return wrapped.filter(Boolean).join('\n\n');
169
+ }
170
+
140
171
  function buildExternalReadmeContext(params = {}) {
141
172
  const appName = params.appName || params.systemKey || 'external-system';
142
173
  const systemKey = params.systemKey || appName;
143
174
  const displayName = params.displayName || formatDisplayName(systemKey);
144
- const description = params.description || `External system integration for ${systemKey}`;
175
+ const rawDescription = params.description || `External system integration for ${systemKey}`;
176
+ const description = wrapPlainTextForMarkdown(rawDescription);
145
177
  const systemType = params.systemType || 'openapi';
146
178
  const fileExt = params.fileExt !== undefined ? params.fileExt : '.json';
147
179
  const normalizedExt = fileExt && fileExt.startsWith('.') ? fileExt : `.${fileExt || 'json'}`;
@@ -186,13 +218,25 @@ function loadExternalReadmeTemplate() {
186
218
  * @param {Object} params - Context parameters
187
219
  * @returns {string} README content
188
220
  */
221
+ /**
222
+ * Collapses 3+ consecutive newlines to 2 (fixes MD012 from Handlebars spacing).
223
+ * @param {string} md - Markdown body
224
+ * @returns {string}
225
+ */
226
+ function collapseConsecutiveBlankLines(md) {
227
+ if (!md || typeof md !== 'string') return md;
228
+ return md.replace(/\n{3,}/g, '\n\n');
229
+ }
230
+
189
231
  function generateExternalReadmeContent(params = {}) {
190
232
  const template = loadExternalReadmeTemplate();
191
233
  const context = buildExternalReadmeContext(params);
192
- return template(context);
234
+ return collapseConsecutiveBlankLines(template(context));
193
235
  }
194
236
 
195
237
  module.exports = {
196
238
  buildExternalReadmeContext,
197
- generateExternalReadmeContent
239
+ generateExternalReadmeContent,
240
+ wrapPlainTextForMarkdown,
241
+ collapseConsecutiveBlankLines
198
242
  };
@@ -175,6 +175,54 @@ function mergeBuilderDirIntoRegistry(merged, builderDir) {
175
175
  }
176
176
  }
177
177
 
178
+ /**
179
+ * True when getBuilderRoot() resolves to the same path as AIFABRIX_BUILDER_DIR (authoritative override).
180
+ * @param {string|null} resolvedEffective
181
+ * @param {string|null} envResolved
182
+ * @returns {boolean}
183
+ */
184
+ function effectiveBuilderMatchesEnvVar(resolvedEffective, envResolved) {
185
+ return Boolean(envResolved && resolvedEffective && resolvedEffective === envResolved);
186
+ }
187
+
188
+ /**
189
+ * Builder dirs to scan in order; later merges overwrite registry keys from earlier dirs.
190
+ * When `AIFABRIX_BUILDER_DIR` is set and differs from projectRoot/builder, env builder root is merged last.
191
+ * Otherwise projectRoot/builder is merged last so explicit refresh roots override getBuilderRoot().
192
+ *
193
+ * @param {string} root - Resolved project root passed to refresh
194
+ * @param {string|null} effectiveBuilderDir - pathsUtil.getBuilderRoot()
195
+ * @returns {string[]}
196
+ */
197
+ function getOrderedBuilderDirsForRegistryScan(root, effectiveBuilderDir) {
198
+ const legacyBuilderDir = path.join(root, 'builder');
199
+ let resolvedLegacy;
200
+ let resolvedEffective;
201
+ try {
202
+ resolvedLegacy = path.resolve(legacyBuilderDir);
203
+ resolvedEffective = effectiveBuilderDir ? path.resolve(effectiveBuilderDir) : null;
204
+ } catch {
205
+ return [legacyBuilderDir];
206
+ }
207
+ const envRaw = process.env.AIFABRIX_BUILDER_DIR && String(process.env.AIFABRIX_BUILDER_DIR).trim();
208
+ const envResolved = envRaw ? path.resolve(envRaw) : null;
209
+ // Only treat env as authoritative when getBuilderRoot() is actually that path. Otherwise a stray
210
+ // AIFABRIX_BUILDER_DIR on CI (or Jest mocking getBuilderRoot to a temp dir) must not force
211
+ // [legacy, effective] — that order lets the real checkout builder overwrite the mocked root last.
212
+ const effectiveMatchesEnvVar = effectiveBuilderMatchesEnvVar(resolvedEffective, envResolved);
213
+
214
+ if (effectiveBuilderDir && resolvedEffective && resolvedEffective === resolvedLegacy) {
215
+ return [legacyBuilderDir];
216
+ }
217
+ if (effectiveMatchesEnvVar && effectiveBuilderDir && resolvedEffective && resolvedEffective !== resolvedLegacy) {
218
+ return [legacyBuilderDir, effectiveBuilderDir];
219
+ }
220
+ if (effectiveBuilderDir && resolvedEffective && resolvedEffective !== resolvedLegacy) {
221
+ return [effectiveBuilderDir, legacyBuilderDir];
222
+ }
223
+ return [legacyBuilderDir];
224
+ }
225
+
178
226
  /**
179
227
  * Merge scan results into registry (does not remove stale keys).
180
228
  * @param {string|null} projectRoot - getProjectRoot() or null (same semantics as projectRoot || getProjectRoot())
@@ -188,19 +236,13 @@ function refreshUrlsLocalRegistryFromBuilder(projectRoot) {
188
236
  }
189
237
  // Published npm tarball omits builder/ under the package root (.npmignore). Global installs must
190
238
  // still refresh from the real builder tree (AIFABRIX_BUILDER_DIR or integration base + builder).
191
- const legacyBuilderDir = path.join(root, 'builder');
192
- const effectiveBuilderDir = pathsUtil.getBuilderRoot();
193
- const builderDirs = [legacyBuilderDir];
239
+ let effectiveBuilderDir = null;
194
240
  try {
195
- if (
196
- effectiveBuilderDir &&
197
- path.resolve(effectiveBuilderDir) !== path.resolve(legacyBuilderDir)
198
- ) {
199
- builderDirs.push(effectiveBuilderDir);
200
- }
241
+ effectiveBuilderDir = pathsUtil.getBuilderRoot();
201
242
  } catch {
202
- /* ignore path resolution errors */
243
+ effectiveBuilderDir = null;
203
244
  }
245
+ const builderDirs = getOrderedBuilderDirsForRegistryScan(root, effectiveBuilderDir);
204
246
  for (const builderDir of builderDirs) {
205
247
  mergeBuilderDirIntoRegistry(merged, builderDir);
206
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.44.4",
3
+ "version": "2.44.5",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -28,8 +28,12 @@ SKIP_FIRST_TIME_SETUP=false
28
28
  # Optional custom controller key for onboarding (default: miso-controller)
29
29
  ONBOARDING_CONTROLLER_KEY=miso-controller
30
30
 
31
- # Optional infrastructure name override for onboarding (default: from INFRASTRUCTURE_NAME or aifabrix)
32
- ONBOARDING_INFRASTRUCTURE_NAME=
31
+ # Infrastructure/service name used by controller/onboarding flows
32
+ INFRASTRUCTURE_NAME=aifabrix
33
+
34
+ # Azure region (required for first-time onboarding: controller record / provisioning metadata).
35
+ # Use the region you deploy to (e.g. westeurope, eastus, northeurope). Single env name: LOCATION.
36
+ LOCATION=westeurope
33
37
 
34
38
  # Required for admin user creation during onboarding
35
39
  # Password for the initial administrator user (username: admin)
@@ -319,10 +323,6 @@ MISO_ENVIRONMENT=miso
319
323
  MISO_CLIENTID=kv://miso-controller-client-idKeyVault
320
324
  MISO_CLIENTSECRET=kv://miso-controller-client-secretKeyVault
321
325
 
322
- # Allowed origins for CORS validation (comma-separated)
323
- # Use wildcards for ports: http://localhost:*
324
- MISO_ALLOWED_ORIGINS=http://localhost:*,url://host-public,url://host-private,url://dataplane-host-public,url://dataplane-host-private
325
-
326
326
  # Evaluation mode (optional .env override of DB controller.configuration.evaluation):
327
327
  # When true (default if DB omits flag), infra deploy may coerce :envKey to `miso` — e2e poll on `dev` can 404.
328
328
  # Set false locally to force path envKey to match deploy + GET .../deployments/:id.