@aifabrix/builder 2.44.6 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.cursor/rules/cli-layout.mdc +7 -3
  2. package/jest.projects.js +56 -0
  3. package/lib/app/helpers.js +3 -3
  4. package/lib/app/index.js +3 -3
  5. package/lib/app/register.js +7 -6
  6. package/lib/app/restart-display.js +52 -21
  7. package/lib/app/rotate-secret.js +7 -6
  8. package/lib/app/run-helpers.js +15 -8
  9. package/lib/app/run.js +57 -9
  10. package/lib/app/show-display.js +7 -0
  11. package/lib/app/show.js +87 -5
  12. package/lib/build/index.js +9 -5
  13. package/lib/cli/infra-guided.js +42 -27
  14. package/lib/cli/installation-log-command.js +73 -0
  15. package/lib/cli/setup-app.js +11 -1
  16. package/lib/cli/setup-auth.js +94 -49
  17. package/lib/cli/setup-infra-up-dataplane-action.js +111 -0
  18. package/lib/cli/setup-infra-up-platform-action.js +131 -0
  19. package/lib/cli/setup-infra.js +60 -119
  20. package/lib/cli/setup-platform.js +1 -1
  21. package/lib/cli/setup-utility-resolve.js +132 -0
  22. package/lib/cli/setup-utility.js +65 -51
  23. package/lib/commands/app-logs.js +81 -33
  24. package/lib/commands/auth-config.js +116 -18
  25. package/lib/commands/setup-modes.js +19 -6
  26. package/lib/commands/setup-prompts.js +41 -8
  27. package/lib/commands/setup.js +114 -9
  28. package/lib/commands/teardown.js +54 -5
  29. package/lib/commands/up-common.js +48 -14
  30. package/lib/commands/up-dataplane.js +21 -18
  31. package/lib/commands/up-miso.js +12 -8
  32. package/lib/commands/upload.js +5 -3
  33. package/lib/core/audit-logger.js +1 -34
  34. package/lib/core/config-admin-email.js +56 -0
  35. package/lib/core/config-normalize.js +60 -0
  36. package/lib/core/config-registered-controller-urls.js +54 -0
  37. package/lib/core/config.js +33 -50
  38. package/lib/core/secrets-ensure-infra.js +1 -1
  39. package/lib/core/secrets-env-content.js +86 -90
  40. package/lib/core/secrets-env-declarative-expand.js +170 -0
  41. package/lib/core/secrets-env-write.js +2 -0
  42. package/lib/core/secrets-load.js +106 -102
  43. package/lib/external-system/deploy.js +5 -1
  44. package/lib/internal/node-fs.js +2 -0
  45. package/lib/schema/application-schema.json +4 -0
  46. package/lib/schema/infra.parameter.yaml +10 -0
  47. package/lib/utils/app-config-resolver.js +24 -1
  48. package/lib/utils/applications-config-defaults.js +206 -0
  49. package/lib/utils/auth-config-validator.js +2 -12
  50. package/lib/utils/bash-secret-env.js +1 -1
  51. package/lib/utils/compose-generate-docker-compose.js +111 -6
  52. package/lib/utils/compose-generator.js +17 -8
  53. package/lib/utils/controller-url.js +50 -7
  54. package/lib/utils/env-copy.js +99 -14
  55. package/lib/utils/env-template.js +5 -1
  56. package/lib/utils/health-check-url.js +18 -15
  57. package/lib/utils/health-check.js +7 -5
  58. package/lib/utils/infra-optional-service-flags.js +69 -0
  59. package/lib/utils/installation-log-core.js +282 -0
  60. package/lib/utils/installation-log-record.js +237 -0
  61. package/lib/utils/installation-log.js +123 -0
  62. package/lib/utils/log-redaction.js +105 -0
  63. package/lib/utils/manifest-location.js +164 -0
  64. package/lib/utils/manifest-source-emit.js +162 -0
  65. package/lib/utils/paths.js +238 -89
  66. package/lib/utils/remote-secrets-loader.js +7 -1
  67. package/lib/utils/run-cli-flags.js +29 -0
  68. package/lib/utils/secrets-canonical.js +10 -3
  69. package/lib/utils/secrets-path.js +3 -4
  70. package/lib/utils/secrets-utils.js +20 -10
  71. package/lib/utils/system-builder-root.js +10 -2
  72. package/lib/utils/url-declarative-public-base.js +80 -12
  73. package/lib/utils/url-declarative-resolve-build-urls.js +238 -0
  74. package/lib/utils/url-declarative-resolve-build.js +24 -393
  75. package/lib/utils/url-declarative-resolve-expand-token.js +189 -0
  76. package/lib/utils/url-declarative-resolve-load-doc.js +12 -3
  77. package/lib/utils/url-declarative-resolve-surface-state.js +102 -0
  78. package/lib/utils/url-declarative-resolve.js +47 -7
  79. package/lib/utils/url-declarative-runtime-base-path.js +21 -1
  80. package/lib/utils/urls-local-registry-scan.js +103 -0
  81. package/lib/utils/urls-local-registry.js +161 -90
  82. package/package.json +3 -1
  83. package/templates/applications/dataplane/application.yaml +4 -0
  84. package/templates/applications/miso-controller/application.yaml +2 -0
  85. package/templates/applications/miso-controller/env.template +27 -29
  86. package/.npmrc.token +0 -1
@@ -10,11 +10,11 @@ const { formatSuccessParagraph } = require('../utils/cli-test-layout-chalk');
10
10
  const path = require('path');
11
11
  const fs = require('fs');
12
12
  const chalk = require('chalk');
13
- const secrets = require('../core/secrets');
14
13
  const generator = require('../generator');
15
14
  const logger = require('../utils/logger');
16
15
  const { handleCommandError, logOfflinePathWhenType } = require('../utils/cli-utils');
17
16
  const { detectAppType, getDeployJsonPath, getResolveAppPath } = require('../utils/paths');
17
+ const { setupResolveCommand } = require('./setup-utility-resolve');
18
18
 
19
19
  const JSON_HELP_AFTER = `
20
20
  Example:
@@ -121,46 +121,20 @@ function logSplitJsonResult(result) {
121
121
  }
122
122
  }
123
123
 
124
- function setupResolveCommand(program) {
125
- program.command('resolve <app>')
126
- .description('Generate .env from template; optional validate after')
127
- .option('-f, --force', 'Generate missing secret keys in secrets file')
128
- .option('--skip-validation', 'Skip file validation after generating .env')
129
- .action(async(appName, options) => {
130
- try {
131
- const { appPath, envOnly } = await getResolveAppPath(appName);
132
- const envPath = await secrets.generateEnvFile(
133
- appName,
134
- undefined,
135
- 'docker',
136
- options.force,
137
- { appPath, envOnly, skipOutputPath: false, preserveFromPath: null }
138
- );
139
- logger.log(`✔ Generated .env file: ${envPath}`);
140
- if (envOnly) {
141
- logger.log(chalk.gray(' (env-only mode: validation skipped; no application config file)'));
142
- } else if (!options.skipValidation) {
143
- const validate = require('../validation/validate');
144
- const result = await validate.validateAppOrFile(appName);
145
- validate.displayValidationResults(result);
146
- if (!result.valid) {
147
- logger.log(chalk.yellow('\n⚠ Validation found errors. Fix them before deploying.'));
148
- process.exit(1);
149
- }
150
- }
151
- } catch (error) {
152
- handleCommandError(error, 'resolve');
153
- process.exit(1);
154
- }
155
- });
156
- }
157
-
158
124
  function setupJsonCommand(program) {
159
125
  program.command('json <app>')
160
126
  .description('Write deployment JSON to disk for version control')
161
127
  .addHelpText('after', JSON_HELP_AFTER)
162
128
  .action(async(appName, options) => {
163
129
  try {
130
+ const resolved = await getResolveAppPath(appName);
131
+ const { emitManifestMetadataLineIfTTY } = require('../utils/manifest-source-emit');
132
+ emitManifestMetadataLineIfTTY(logger, {
133
+ appKey: appName,
134
+ appPath: resolved.appPath,
135
+ envOnly: resolved.envOnly,
136
+ json: false
137
+ });
164
138
  const result = await generator.generateDeployJsonWithValidation(appName, options);
165
139
  if (result.success) {
166
140
  const fileName = result.path.includes('application-schema.json') ? 'application-schema.json' : 'deployment JSON';
@@ -353,28 +327,56 @@ function setupSplitJsonConvertShowCommands(program) {
353
327
  setupShowCommand(program);
354
328
  }
355
329
 
356
- async function runValidateCommand(appOrFile, options) {
357
- const validate = require('../validation/validate');
358
- const opts = options.opts ? options.opts() : options;
330
+ function emitManifestAfterValidateIfApplicable(appOrFile, result, outFormat) {
331
+ if (outFormat === 'json' || !result?.appPath || !appOrFile || typeof appOrFile !== 'string') {
332
+ return;
333
+ }
334
+ try {
335
+ if (fs.existsSync(appOrFile) && fs.statSync(appOrFile).isFile()) {
336
+ return;
337
+ }
338
+ const { emitManifestMetadataLineIfTTY } = require('../utils/manifest-source-emit');
339
+ emitManifestMetadataLineIfTTY(logger, {
340
+ appKey: appOrFile,
341
+ appPath: result.appPath,
342
+ envOnly: false,
343
+ json: false
344
+ });
345
+ } catch {
346
+ /* ignore emit failures */
347
+ }
348
+ }
349
+
350
+ async function runValidateBatchBranch(validate, opts) {
359
351
  const integration = opts.integration === true;
360
352
  const builder = opts.builder === true;
353
+ if (!integration && !builder) {
354
+ return false;
355
+ }
361
356
  const outFormat = (opts.format || 'default').toLowerCase();
357
+ const batchResult = integration && builder
358
+ ? await validate.validateAll(opts)
359
+ : integration
360
+ ? await validate.validateAllIntegrations(opts)
361
+ : await validate.validateAllBuilderApps(opts);
362
+ if (outFormat === 'json') {
363
+ logger.log(JSON.stringify(batchResult, null, 2));
364
+ } else {
365
+ validate.displayBatchValidationResults(batchResult);
366
+ }
367
+ if (!batchResult.valid) process.exit(1);
368
+ return true;
369
+ }
362
370
 
363
- if (integration || builder) {
364
- const batchResult = integration && builder
365
- ? await validate.validateAll(opts)
366
- : integration
367
- ? await validate.validateAllIntegrations(opts)
368
- : await validate.validateAllBuilderApps(opts);
369
- if (outFormat === 'json') {
370
- logger.log(JSON.stringify(batchResult, null, 2));
371
- } else {
372
- validate.displayBatchValidationResults(batchResult);
373
- }
374
- if (!batchResult.valid) process.exit(1);
371
+ async function runValidateCommand(appOrFile, options) {
372
+ const validate = require('../validation/validate');
373
+ const opts = options.opts ? options.opts() : options;
374
+ if (await runValidateBatchBranch(validate, opts)) {
375
375
  return;
376
376
  }
377
377
 
378
+ const outFormat = (opts.format || 'default').toLowerCase();
379
+
378
380
  if (!appOrFile || typeof appOrFile !== 'string') {
379
381
  logger.log(chalk.red('App name or file path is required, or use --integration / --builder'));
380
382
  process.exit(1);
@@ -384,8 +386,20 @@ async function runValidateCommand(appOrFile, options) {
384
386
  ...opts,
385
387
  certSync: opts.certSync === true
386
388
  });
389
+ emitManifestAfterValidateIfApplicable(appOrFile, result, outFormat);
387
390
  if (outFormat === 'json') {
388
- logger.log(JSON.stringify(result, null, 2));
391
+ let payload = result;
392
+ try {
393
+ if (result?.appPath && appOrFile && typeof appOrFile === 'string') {
394
+ if (!(fs.existsSync(appOrFile) && fs.statSync(appOrFile).isFile())) {
395
+ const { getManifestSourcePayload } = require('../utils/manifest-source-emit');
396
+ payload = { ...result, manifestSource: getManifestSourcePayload(appOrFile, result.appPath) };
397
+ }
398
+ }
399
+ } catch {
400
+ /* keep plain result */
401
+ }
402
+ logger.log(JSON.stringify(payload, null, 2));
389
403
  } else {
390
404
  validate.displayValidationResults(result);
391
405
  }
@@ -15,6 +15,7 @@ const containerHelpers = require('../utils/app-run-containers');
15
15
  const { validateAppName } = require('../app/push');
16
16
 
17
17
  const { execWithDockerEnv } = require('../utils/docker-exec');
18
+ const { maskEnvLine } = require('../utils/log-redaction');
18
19
 
19
20
  /** Default number of log lines */
20
21
  const DEFAULT_TAIL_LINES = 100;
@@ -43,36 +44,11 @@ const LEVEL_JSON_NUMERIC_REGEX = /"level"\s*:\s*(\d+)/;
43
44
  /** Fallback: line contains whole-word "error" or "Error" when no other level detected (catches stack traces, "Error: msg", etc.) */
44
45
  const ERROR_WORD_FALLBACK_REGEX = /\berror\b/i;
45
46
 
46
- /** Env key patterns that indicate a secret (mask value) */
47
- const SECRET_KEY_PATTERN = /password|secret|token|credential|api[_-]?key/i;
47
+ /** Keycloak / Java style: validation failure without the word "error" (would be dropped by `-l error` otherwise) */
48
+ const VALIDATION_FAILURE_LINE_REGEX = /\bvalidation\s+failed\b/i;
48
49
 
49
- /** Prefixes to strip before checking key (avoids masking KEYCLOAK_SERVER_URL etc.) */
50
- const KEY_PREFIXES_TO_STRIP = /^KEYCLOAK_|^KEY_VAULT_/;
51
-
52
- /** URL with embedded credentials: scheme://user:password@host → scheme://user:***@host */
53
- const URL_CREDENTIAL_PATTERN = /(\w+:\/\/)([^:@]*):([^@]+)@/g;
54
-
55
- /**
56
- * Masks a single env line if the key looks like a secret or value contains URL credentials
57
- * @param {string} line - Line in form KEY=value
58
- * @returns {string} Same line or KEY=*** or value with masked URL credentials
59
- */
60
- function maskEnvLine(line) {
61
- const eq = line.indexOf('=');
62
- if (eq <= 0) return line;
63
- const key = line.slice(0, eq);
64
- const value = line.slice(eq + 1);
65
-
66
- const keyForCheck = key.replace(KEY_PREFIXES_TO_STRIP, '');
67
- const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck);
68
-
69
- const maskedValue = value.replace(URL_CREDENTIAL_PATTERN, '$1$2:***@');
70
- const hasUrlCredentials = maskedValue !== value;
71
-
72
- if (isSecretKey) return `${key}=***`;
73
- if (hasUrlCredentials) return `${key}=${maskedValue}`;
74
- return line;
75
- }
50
+ /** Other failure phrases treated as error severity for level filtering */
51
+ const FAILURE_PHRASE_AS_ERROR_REGEX = /\b(fatal|failed to run|unable to (start|run)|exception in thread)\b/i;
76
52
 
77
53
  /** Normalize level string to canonical 'debug'|'info'|'warn'|'error'. */
78
54
  function normalizeLevel(raw) {
@@ -104,9 +80,66 @@ function getLogLevel(line) {
104
80
  const jsonNum = line.match(LEVEL_JSON_NUMERIC_REGEX);
105
81
  if (jsonNum) return numericLevelToName(parseInt(jsonNum[1], 10));
106
82
  if (ERROR_WORD_FALLBACK_REGEX.test(line)) return 'error';
83
+ if (VALIDATION_FAILURE_LINE_REGEX.test(line)) return 'error';
84
+ if (FAILURE_PHRASE_AS_ERROR_REGEX.test(line)) return 'error';
85
+ return null;
86
+ }
87
+
88
+ /** Lines after an `error`-level line often have no level prefix (e.g. Keycloak config validation details). */
89
+ const ERROR_FILTER_CONTEXT_UNCLASSIFIED_MAX = 50;
90
+
91
+ /**
92
+ * Whether to show a log line when `--level` filtering is on. After an emitted error line, includes
93
+ * up to {@link ERROR_FILTER_CONTEXT_UNCLASSIFIED_MAX} following lines with no parseable level so
94
+ * stack traces and multi-line validation output are not dropped.
95
+ *
96
+ * @param {string} line
97
+ * @param {string|null} minLevel - normalized LOG_LEVELS value or null
98
+ * @param {{ pendingAfterError: number }} state - mutable; only used when minLevel is `error`
99
+ * @returns {boolean}
100
+ */
101
+ /**
102
+ * @param {string|null} level
103
+ * @param {string} minLevel
104
+ * @param {{ pendingAfterError: number }} state
105
+ * @returns {boolean|null} true if line shown by sticky rule; null to continue
106
+ */
107
+ function tryStickyErrorContinuation(level, minLevel, state) {
108
+ if (minLevel !== 'error' || state.pendingAfterError <= 0) {
109
+ return null;
110
+ }
111
+ if (level === null || level === undefined) {
112
+ state.pendingAfterError -= 1;
113
+ return true;
114
+ }
115
+ if (level !== 'error') {
116
+ state.pendingAfterError = 0;
117
+ }
107
118
  return null;
108
119
  }
109
120
 
121
+ function shouldShowFilteredLogLine(line, minLevel, state) {
122
+ if (minLevel === null || minLevel === undefined || minLevel === '' || !LOG_LEVELS.includes(minLevel)) {
123
+ return true;
124
+ }
125
+ if (line.trim() === '' && minLevel === 'error' && state.pendingAfterError > 0) {
126
+ return true;
127
+ }
128
+ const level = getLogLevel(line);
129
+ const sticky = tryStickyErrorContinuation(level, minLevel, state);
130
+ if (sticky === true) {
131
+ return true;
132
+ }
133
+
134
+ if (passesLevelFilter(level, minLevel)) {
135
+ if (minLevel === 'error' && level === 'error') {
136
+ state.pendingAfterError = ERROR_FILTER_CONTEXT_UNCLASSIFIED_MAX;
137
+ }
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+
110
143
  /**
111
144
  * Whether a line's level passes the minimum level filter (show this level and above).
112
145
  * @param {string|null} lineLevel - Level from getLogLevel (null treated as 'info')
@@ -143,7 +176,12 @@ async function dumpMaskedEnv(containerName) {
143
176
  lines.forEach((line) => logger.log(maskEnvLine(line)));
144
177
  logger.log(chalk.gray('\n--- Logs ---\n'));
145
178
  } catch (err) {
146
- logger.log(chalk.gray('(Could not read container env; container may be stopped)\n'));
179
+ logger.log(
180
+ chalk.gray(
181
+ '(Could not read container env — the container may be stopped, restarting, or not ready yet. ' +
182
+ 'Docker log output below is still shown.)\n'
183
+ )
184
+ );
147
185
  }
148
186
  }
149
187
 
@@ -175,8 +213,9 @@ async function runDockerLogs(containerName, options) {
175
213
  const proc = spawn('docker', args, { stdio: ['inherit', 'pipe', 'pipe'], env: dockerEnv });
176
214
  proc.on('error', reject);
177
215
 
216
+ const filterState = { pendingAfterError: 0 };
178
217
  function onLine(line) {
179
- if (passesLevelFilter(getLogLevel(line), minLevel)) {
218
+ if (shouldShowFilteredLogLine(line, minLevel, filterState)) {
180
219
  process.stdout.write(line + '\n');
181
220
  }
182
221
  }
@@ -241,8 +280,11 @@ async function runDockerLogsFollow(containerName, tail, minLevel) {
241
280
  logger.log(chalk.red(`Error: ${err.message}`));
242
281
  process.exit(1);
243
282
  });
283
+ const filterState = { pendingAfterError: 0 };
244
284
  function onLine(line) {
245
- if (passesLevelFilter(getLogLevel(line), level)) process.stdout.write(line + '\n');
285
+ if (shouldShowFilteredLogLine(line, level, filterState)) {
286
+ process.stdout.write(line + '\n');
287
+ }
246
288
  }
247
289
  const rlOut = readline.createInterface({ input: proc.stdout, crlfDelay: Infinity });
248
290
  rlOut.on('line', onLine);
@@ -301,4 +343,10 @@ async function runAppLogs(appKey, options = {}) {
301
343
  }
302
344
  }
303
345
 
304
- module.exports = { runAppLogs, maskEnvLine, getLogLevel, passesLevelFilter };
346
+ module.exports = {
347
+ runAppLogs,
348
+ maskEnvLine,
349
+ getLogLevel,
350
+ passesLevelFilter,
351
+ shouldShowFilteredLogLine
352
+ };
@@ -8,53 +8,144 @@
8
8
  * @version 2.0.0
9
9
  */
10
10
 
11
+ const chalk = require('chalk');
11
12
  const { formatBlockingError, formatSuccessLine } = require('../utils/cli-test-layout-chalk');
12
13
  const {
13
14
  setControllerUrl,
14
15
  setCurrentEnvironment,
15
- getControllerUrl
16
+ getControllerUrl,
17
+ getRegisteredControllerUrls,
18
+ getConfig
16
19
  } = require('../core/config');
17
20
  const {
18
21
  validateControllerUrl,
19
22
  validateEnvironment,
20
23
  checkUserLoggedIn
21
24
  } = require('../utils/auth-config-validator');
22
- const { getControllerUrlFromLoggedInUser } = require('../utils/controller-url');
25
+ const { hasStoredDeviceTokenForController } = require('../utils/controller-url');
23
26
  const logger = require('../utils/logger');
24
27
 
28
+ /**
29
+ * True when user ran --set-controller with no URL (pick from config.yaml).
30
+ * @param {unknown} setController - Commander option value
31
+ * @returns {boolean}
32
+ */
33
+ function isInteractiveControllerPick(setController) {
34
+ return (
35
+ setController === true ||
36
+ (typeof setController === 'string' && setController.trim() === '')
37
+ );
38
+ }
39
+
40
+ /**
41
+ * True when a non-empty controller URL string was passed.
42
+ * @param {unknown} setController - Commander option value
43
+ * @returns {boolean}
44
+ */
45
+ function hasExplicitControllerUrl(setController) {
46
+ return typeof setController === 'string' && setController.trim() !== '';
47
+ }
48
+
49
+ /**
50
+ * Normalize controller URL for equality checks (trailing slashes).
51
+ * @param {string|null|undefined} url - URL or empty
52
+ * @returns {string}
53
+ */
54
+ function normalizeForCompare(url) {
55
+ if (!url || typeof url !== 'string') return '';
56
+ return url.trim().replace(/\/+$/, '');
57
+ }
58
+
59
+ function throwNoRegisteredControllers() {
60
+ const msg =
61
+ 'No controllers are registered in config. Run "aifabrix login" first, or set a controller with "aifabrix auth --set-controller <url>".';
62
+ logger.error(formatBlockingError(msg));
63
+ throw new Error(msg);
64
+ }
65
+
66
+ function throwNonInteractiveControllerPick() {
67
+ const msg =
68
+ 'Cannot choose a controller without a URL in non-interactive mode. Run: aifabrix auth --set-controller <url>';
69
+ logger.error(formatBlockingError(msg));
70
+ throw new Error(msg);
71
+ }
72
+
73
+ /**
74
+ * Pick default controller from URLs in config (`controller` + `device` keys), or set the only one.
75
+ * @async
76
+ * @returns {Promise<void>}
77
+ */
78
+ async function handleSelectRegisteredController() {
79
+ const urls = await getRegisteredControllerUrls();
80
+
81
+ if (urls.length === 0) {
82
+ throwNoRegisteredControllers();
83
+ }
84
+
85
+ if (!process.stdin.isTTY) {
86
+ throwNonInteractiveControllerPick();
87
+ }
88
+
89
+ if (urls.length === 1) {
90
+ const sole = urls[0];
91
+ const current = await getControllerUrl();
92
+ if (normalizeForCompare(current) === normalizeForCompare(sole)) {
93
+ logger.log(formatSuccessLine(`Default controller is already set to ${sole}.`));
94
+ logger.log(
95
+ chalk.white('To add another controller, run: aifabrix auth --set-controller <url>')
96
+ );
97
+ return;
98
+ }
99
+ await handleSetController(sole);
100
+ return;
101
+ }
102
+
103
+ const inquirer = require('inquirer');
104
+ const { controllerUrl } = await inquirer.prompt([
105
+ {
106
+ type: 'list',
107
+ name: 'controllerUrl',
108
+ message: 'Select default controller:',
109
+ choices: urls
110
+ }
111
+ ]);
112
+ await handleSetController(controllerUrl);
113
+ }
114
+
25
115
  /**
26
116
  * Handle set-controller command
27
- * Allows setting the default controller when no credentials are stored, or when already logged in to that controller.
28
- * If credentials exist for a different controller, throws with a clear message.
117
+ * Allows setting the default controller when there are no device tokens, or when a token exists
118
+ * for the target URL (including switching among multiple logged-in controllers). If device tokens
119
+ * exist only for other controller URLs, throws with a clear message.
29
120
  *
30
121
  * @async
31
122
  * @function handleSetController
32
123
  * @param {string} url - Controller URL to set
33
124
  * @returns {Promise<void>}
34
- * @throws {Error} If validation fails or credentials exist for another controller
125
+ * @throws {Error} If validation fails or credentials exist only for other controllers
35
126
  */
36
127
  async function handleSetController(url) {
37
128
  try {
38
129
  validateControllerUrl(url);
39
130
  const normalizedUrl = url.trim().replace(/\/+$/, '');
40
131
 
41
- const loggedInControllerUrl = await getControllerUrlFromLoggedInUser();
42
- if (!loggedInControllerUrl) {
43
- // No stored credentials: allow setting controller so "aifabrix login" opens the right place
44
- await setControllerUrl(url);
45
- logger.log(formatSuccessLine(`Controller URL set to: ${url}`));
46
- return;
47
- }
132
+ const userConfig = await getConfig();
133
+ const device =
134
+ userConfig.device && typeof userConfig.device === 'object' ? userConfig.device : {};
135
+ const deviceKeys = Object.keys(device);
136
+ const hasTokenForTarget = await hasStoredDeviceTokenForController(normalizedUrl);
48
137
 
49
- const normalizedLoggedIn = loggedInControllerUrl.trim().replace(/\/+$/, '');
50
- if (normalizedLoggedIn === normalizedUrl) {
138
+ if (deviceKeys.length === 0 || hasTokenForTarget) {
51
139
  await setControllerUrl(url);
52
140
  logger.log(formatSuccessLine(`Controller URL set to: ${url}`));
53
141
  return;
54
142
  }
55
143
 
144
+ const otherKey =
145
+ deviceKeys.find((k) => normalizeForCompare(k) !== normalizeForCompare(normalizedUrl)) ||
146
+ deviceKeys[0];
56
147
  throw new Error(
57
- `You have credentials for another controller (${loggedInControllerUrl}).\n` +
148
+ `You have credentials for another controller (${otherKey.trim().replace(/\/+$/, '')}).\n` +
58
149
  'To use a different controller either run "aifabrix login" with that controller, or run "aifabrix logout" first to clear credentials, then set the new controller with "aifabrix auth --set-controller <url>".'
59
150
  );
60
151
  } catch (error) {
@@ -107,20 +198,27 @@ async function handleSetEnvironment(environment) {
107
198
  * @async
108
199
  * @function handleAuthConfig
109
200
  * @param {Object} options - Command options
110
- * @param {string} [options.setController] - Controller URL to set
201
+ * @param {string|boolean} [options.setController] - Controller URL, or true when flag has no value
111
202
  * @param {string} [options.setEnvironment] - Environment to set
112
203
  * @returns {Promise<void>}
113
204
  * @throws {Error} If command fails
114
205
  */
115
206
  async function handleAuthConfig(options) {
116
- if (!options.setController && !options.setEnvironment) {
207
+ const pick = isInteractiveControllerPick(options.setController);
208
+ const hasUrl = hasExplicitControllerUrl(options.setController);
209
+
210
+ if (!pick && !hasUrl && !options.setEnvironment) {
117
211
  throw new Error(
118
212
  'No action specified. Use "aifabrix auth --set-controller <url>" or "aifabrix auth --set-environment <env>".'
119
213
  );
120
214
  }
121
- if (options.setController) {
215
+
216
+ if (pick) {
217
+ await handleSelectRegisteredController();
218
+ } else if (hasUrl) {
122
219
  await handleSetController(options.setController);
123
220
  }
221
+
124
222
  if (options.setEnvironment) {
125
223
  await handleSetEnvironment(options.setEnvironment);
126
224
  }
@@ -9,7 +9,9 @@
9
9
  * - {@link runUpdateImages} — Mode 4: docker pull infra + platform images, up-infra, up-platform.
10
10
  *
11
11
  * Every handler ends with `up-infra` (idempotent, reads flags from
12
- * `config.yaml`) followed by `up-platform`. Modes 1/2/3 also remove
12
+ * `config.yaml`) followed by `up-platform`. Optional service keys
13
+ * (`traefik`, `pgadmin`, `redisCommander`) are written when missing so the
14
+ * file matches effective compose defaults. Modes 1/2/3 also remove
13
15
  * `secrets.local.yaml` so the next platform bootstrap re-resolves
14
16
  * service secrets from the catalog and re-prompts the AI tool wizard.
15
17
  *
@@ -49,6 +51,10 @@ const postgresWipe = require('../utils/postgres-wipe');
49
51
  const setupPrompts = require('./setup-prompts');
50
52
  const { handleLogin } = require('./login');
51
53
  const infraGuided = require('../cli/infra-guided');
54
+ const {
55
+ computeEffectiveInfraOptionalFlags,
56
+ persistMissingInfraOptionalServiceFlags
57
+ } = require('../utils/infra-optional-service-flags');
52
58
 
53
59
  /** Builder app keys touched by `up-platform --force`. */
54
60
  const PLATFORM_APPS = ['keycloak', 'miso-controller', 'dataplane'];
@@ -203,7 +209,9 @@ function stopSpinnerSuccess(spinner, text) {
203
209
  /**
204
210
  * Start infrastructure using the developer's `config.yaml` flags.
205
211
  * Mirrors the relevant slice of `runUpInfraCommand` from `lib/cli/setup-infra.js`
206
- * without re-applying CLI flag persistence (we never receive flags here).
212
+ * without re-applying CLI flag persistence (setup passes no Commander flags).
213
+ * After a successful start, missing `traefik` / `pgadmin` / `redisCommander`
214
+ * keys are written to match effective {@link infra.startInfra} options.
207
215
  *
208
216
  * @async
209
217
  * @param {Object} [overrides] - Optional admin overrides (fresh install only)
@@ -214,17 +222,22 @@ function stopSpinnerSuccess(spinner, text) {
214
222
  async function startInfraFromConfig(overrides = {}) {
215
223
  await config.ensureSecretsEncryptionKey();
216
224
  const cfg = await config.getConfig();
225
+ const emailOverride = String(overrides.adminEmail || '').trim();
226
+ const emailFromConfig = typeof cfg.adminEmail === 'string' ? cfg.adminEmail.trim() : '';
227
+ const adminEmailMerged = emailOverride || emailFromConfig || undefined;
228
+ const effective = computeEffectiveInfraOptionalFlags(cfg, {});
217
229
  await withMutedLogger(async() => {
218
230
  await infra.startInfra(null, {
219
- traefik: cfg.traefik === true,
220
- pgadmin: cfg.pgadmin !== false,
221
- redisCommander: cfg.redisCommander !== false,
231
+ traefik: effective.traefik,
232
+ pgadmin: effective.pgadmin,
233
+ redisCommander: effective.redisCommander,
222
234
  adminPassword: overrides.adminPassword,
223
235
  adminPwd: overrides.adminPassword,
224
- adminEmail: overrides.adminEmail,
236
+ adminEmail: adminEmailMerged,
225
237
  tlsEnabled: cfg.tlsEnabled === true
226
238
  });
227
239
  });
240
+ await persistMissingInfraOptionalServiceFlags(cfg, effective);
228
241
  }
229
242
 
230
243
  /**
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Three small prompt helpers:
5
5
  * 1. {@link promptModeSelection} - Mode menu shown when infra is already up.
6
- * 2. {@link promptAdminCredentials} - Admin email + password (fresh install only).
6
+ * 2. {@link promptAdminCredentials} - Admin email + password (fresh install only); email default
7
+ * from {@link module:lib/core/config.getAdminEmail} when `adminEmail` exists in config.yaml.
7
8
  * 3. {@link promptAiTool} - AI tool provider + keys, only when the merged
8
9
  * secret cascade (user-local + shared) does not already provide the keys.
9
10
  *
@@ -23,9 +24,11 @@
23
24
 
24
25
  'use strict';
25
26
 
27
+ const path = require('path');
26
28
  const inquirer = require('inquirer');
27
29
  const chalk = require('chalk');
28
30
  const logger = require('../utils/logger');
31
+ const config = require('../core/config');
29
32
  const secretsCore = require('../core/secrets');
30
33
  const secretsEnsure = require('../core/secrets-ensure');
31
34
  const { formatSuccessLine, infoLine } = require('../utils/cli-test-layout-chalk');
@@ -133,6 +136,8 @@ function validatePassword(input) {
133
136
 
134
137
  /**
135
138
  * Prompt for admin email + password (fresh install only).
139
+ * Reads {@link module:lib/core/config.getAdminEmail} so a previous `aifabrix setup` can prefill the
140
+ * email field (user may change it before continuing).
136
141
  * Asks once for the password and once for confirmation; returns trimmed email
137
142
  * and the verbatim password (never logged).
138
143
  *
@@ -140,13 +145,24 @@ function validatePassword(input) {
140
145
  * @returns {Promise<{ adminEmail: string, adminPassword: string }>}
141
146
  */
142
147
  async function promptAdminCredentials() {
148
+ let defaultEmail;
149
+ try {
150
+ const saved = await config.getAdminEmail();
151
+ defaultEmail = saved && saved.trim() ? saved.trim() : undefined;
152
+ } catch {
153
+ defaultEmail = undefined;
154
+ }
155
+ const emailQuestion = {
156
+ type: 'input',
157
+ name: 'adminEmail',
158
+ message: 'Admin email (Keycloak / pgAdmin login):',
159
+ validate: validateEmail
160
+ };
161
+ if (defaultEmail !== undefined) {
162
+ emailQuestion.default = defaultEmail;
163
+ }
143
164
  const answers = await inquirer.prompt([
144
- {
145
- type: 'input',
146
- name: 'adminEmail',
147
- message: 'Admin email (Keycloak / pgAdmin login):',
148
- validate: validateEmail
149
- },
165
+ emailQuestion,
150
166
  {
151
167
  type: 'password',
152
168
  name: 'adminPassword',
@@ -323,6 +339,22 @@ async function promptAiTool(options = {}) {
323
339
  );
324
340
  }
325
341
 
342
+ /**
343
+ * Absolute directory paths (with trailing sep) shown when setup will replace platform apps.
344
+ *
345
+ * @param {string} builderRoot - Absolute or relative builder root
346
+ * @param {string[]} platformApps - App folder names (e.g. `keycloak`)
347
+ * @returns {string[]}
348
+ */
349
+ function formatBuilderPlatformReplaceLines(builderRoot, platformApps) {
350
+ const root = builderRoot && typeof builderRoot === 'string' ? builderRoot : 'builder/';
351
+ const apps = Array.isArray(platformApps) ? platformApps : [];
352
+ return apps.map((a) => {
353
+ const full = path.resolve(path.join(root, a));
354
+ return full.endsWith(path.sep) ? full : `${full}${path.sep}`;
355
+ });
356
+ }
357
+
326
358
  /**
327
359
  * When `aifabrix setup` is about to run `up-platform --force`, it will wipe
328
360
  * platform app directories under `builder/`. If the builder root already
@@ -356,7 +388,7 @@ async function promptBuilderDirConflict(info) {
356
388
  systemNote +
357
389
  '\n\n' +
358
390
  'This setup path will REPLACE the platform app folders under:\n' +
359
- ` ${platformApps.map(a => `builder/${a}/`).join('\n ')}`;
391
+ ` ${formatBuilderPlatformReplaceLines(builderRoot, platformApps).join('\n ')}`;
360
392
 
361
393
  const { action } = await inquirer.prompt([
362
394
  {
@@ -379,6 +411,7 @@ module.exports = {
379
411
  MODE,
380
412
  AI_KEYS,
381
413
  BUILDER_DIR_ACTION,
414
+ formatBuilderPlatformReplaceLines,
382
415
  promptModeSelection,
383
416
  confirmDestructiveMode,
384
417
  promptAdminCredentials,