@aifabrix/builder 2.40.2 → 2.41.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 (103) hide show
  1. package/README.md +6 -4
  2. package/integration/hubspot/test.js +1 -1
  3. package/lib/api/credential.api.js +40 -0
  4. package/lib/api/dev.api.js +423 -0
  5. package/lib/api/types/credential.types.js +23 -0
  6. package/lib/api/types/dev.types.js +140 -0
  7. package/lib/app/config.js +21 -0
  8. package/lib/app/down.js +2 -1
  9. package/lib/app/index.js +9 -0
  10. package/lib/app/push.js +36 -12
  11. package/lib/app/readme.js +1 -3
  12. package/lib/app/run-env-compose.js +201 -0
  13. package/lib/app/run-helpers.js +121 -118
  14. package/lib/app/run.js +148 -28
  15. package/lib/app/show.js +5 -2
  16. package/lib/build/index.js +11 -3
  17. package/lib/cli/setup-app.js +140 -14
  18. package/lib/cli/setup-dev.js +180 -17
  19. package/lib/cli/setup-environment.js +4 -2
  20. package/lib/cli/setup-external-system.js +71 -21
  21. package/lib/cli/setup-infra.js +29 -2
  22. package/lib/cli/setup-secrets.js +52 -5
  23. package/lib/cli/setup-utility.js +12 -3
  24. package/lib/commands/app-install.js +172 -0
  25. package/lib/commands/app-shell.js +75 -0
  26. package/lib/commands/app-test.js +282 -0
  27. package/lib/commands/app.js +1 -1
  28. package/lib/commands/dev-cli-handlers.js +141 -0
  29. package/lib/commands/dev-down.js +114 -0
  30. package/lib/commands/dev-init.js +309 -0
  31. package/lib/commands/secrets-list.js +118 -0
  32. package/lib/commands/secrets-remove.js +97 -0
  33. package/lib/commands/secrets-set.js +30 -17
  34. package/lib/commands/secrets-validate.js +50 -0
  35. package/lib/commands/up-dataplane.js +2 -2
  36. package/lib/commands/up-miso.js +0 -25
  37. package/lib/commands/upload.js +26 -1
  38. package/lib/core/admin-secrets.js +96 -0
  39. package/lib/core/secrets-ensure.js +378 -0
  40. package/lib/core/secrets-env-write.js +157 -0
  41. package/lib/core/secrets.js +147 -81
  42. package/lib/datasource/field-reference-validator.js +91 -0
  43. package/lib/datasource/validate.js +21 -3
  44. package/lib/deployment/environment-config.js +137 -0
  45. package/lib/deployment/environment.js +21 -98
  46. package/lib/deployment/push.js +32 -2
  47. package/lib/external-system/download.js +7 -0
  48. package/lib/external-system/test-auth.js +7 -3
  49. package/lib/external-system/test.js +5 -1
  50. package/lib/generator/index.js +174 -25
  51. package/lib/generator/wizard.js +8 -0
  52. package/lib/infrastructure/helpers.js +103 -20
  53. package/lib/infrastructure/index.js +88 -10
  54. package/lib/infrastructure/services.js +70 -15
  55. package/lib/schema/application-schema.json +24 -3
  56. package/lib/schema/external-system.schema.json +435 -413
  57. package/lib/utils/api.js +3 -3
  58. package/lib/utils/app-register-auth.js +25 -3
  59. package/lib/utils/cli-utils.js +20 -0
  60. package/lib/utils/compose-generator.js +76 -75
  61. package/lib/utils/compose-handlebars-helpers.js +43 -0
  62. package/lib/utils/compose-vector-helper.js +18 -0
  63. package/lib/utils/config-paths.js +127 -2
  64. package/lib/utils/credential-secrets-env.js +267 -0
  65. package/lib/utils/dev-cert-helper.js +122 -0
  66. package/lib/utils/device-code-helpers.js +224 -0
  67. package/lib/utils/device-code.js +37 -336
  68. package/lib/utils/docker-build.js +40 -8
  69. package/lib/utils/env-copy.js +83 -13
  70. package/lib/utils/env-map.js +35 -5
  71. package/lib/utils/env-template.js +6 -5
  72. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  73. package/lib/utils/help-builder.js +15 -2
  74. package/lib/utils/infra-status.js +30 -1
  75. package/lib/utils/local-secrets.js +7 -52
  76. package/lib/utils/mutagen-install.js +195 -0
  77. package/lib/utils/mutagen.js +146 -0
  78. package/lib/utils/paths.js +43 -33
  79. package/lib/utils/port-resolver.js +28 -16
  80. package/lib/utils/remote-dev-auth.js +38 -0
  81. package/lib/utils/remote-docker-env.js +43 -0
  82. package/lib/utils/remote-secrets-loader.js +60 -0
  83. package/lib/utils/secrets-generator.js +94 -6
  84. package/lib/utils/secrets-helpers.js +33 -25
  85. package/lib/utils/secrets-path.js +2 -2
  86. package/lib/utils/secrets-utils.js +52 -1
  87. package/lib/utils/secrets-validation.js +84 -0
  88. package/lib/utils/ssh-key-helper.js +116 -0
  89. package/lib/utils/token-manager-messages.js +90 -0
  90. package/lib/utils/token-manager.js +5 -4
  91. package/lib/utils/variable-transformer.js +3 -3
  92. package/lib/validation/validator.js +65 -0
  93. package/package.json +2 -2
  94. package/scripts/install-local.js +34 -15
  95. package/templates/README.md +0 -1
  96. package/templates/applications/README.md.hbs +4 -4
  97. package/templates/applications/dataplane/application.yaml +5 -4
  98. package/templates/applications/dataplane/env.template +12 -7
  99. package/templates/applications/keycloak/env.template +2 -0
  100. package/templates/applications/miso-controller/application.yaml +1 -0
  101. package/templates/applications/miso-controller/env.template +11 -9
  102. package/templates/python/docker-compose.hbs +49 -23
  103. package/templates/typescript/docker-compose.hbs +48 -22
package/lib/utils/api.js CHANGED
@@ -13,8 +13,8 @@
13
13
  const { parseErrorResponse } = require('./api-error-handler');
14
14
  const auditLogger = require('../core/audit-logger');
15
15
 
16
- /** Default timeout for HTTP requests (ms). Prevents hanging when the controller is unreachable. */
17
- const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
16
+ /** Default timeout for HTTP requests (ms). Prevents hanging when the controller is unreachable. 30s allows Azure Web App cold start to complete. */
17
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
18
18
 
19
19
  /**
20
20
  * Logs API request performance metrics and errors to audit log
@@ -229,7 +229,7 @@ async function handleNetworkError(error, url, options, duration) {
229
229
 
230
230
  /**
231
231
  * Make an API call with proper error handling
232
- * Uses a 15s timeout to avoid hanging when the controller is unreachable.
232
+ * Uses a 30s timeout to avoid hanging when the controller is unreachable (Azure cold start can exceed 5s).
233
233
  * @param {string} url - API endpoint URL
234
234
  * @param {Object} options - Fetch options (signal, method, headers, body, etc.)
235
235
  * @returns {Promise<Object>} Response object with success flag
@@ -257,7 +257,22 @@ async function attemptGetAuthenticationToken(normalizedControllerUrl, config, at
257
257
  return { token, finalControllerUrl, lastError };
258
258
  }
259
259
 
260
- async function checkAuthentication(controllerUrl, _environment) {
260
+ /**
261
+ * When auth fails and throwOnFailure is true, throws an error with controllerUrl so caller can run login and retry.
262
+ * @param {string|null} controllerUrl - Controller URL to attach to the thrown error
263
+ * @param {Error|null} lastError - Last error from token attempt
264
+ * @param {string[]} attemptedUrls - URLs that were tried
265
+ * @throws {Error} Error with .controllerUrl and .authFailure set
266
+ */
267
+ function throwAuthFailureError(controllerUrl, lastError, attemptedUrls) {
268
+ const message = lastError ? lastError.message : 'No valid authentication found';
269
+ const err = new Error(message);
270
+ err.controllerUrl = controllerUrl || (attemptedUrls && attemptedUrls[0]) || null;
271
+ err.authFailure = true;
272
+ throw err;
273
+ }
274
+
275
+ async function checkAuthentication(controllerUrl, _environment, options = {}) {
261
276
  try {
262
277
  const config = await getConfig();
263
278
  const normalizedControllerUrl = normalizeControllerUrlIfProvided(controllerUrl);
@@ -269,8 +284,12 @@ async function checkAuthentication(controllerUrl, _environment) {
269
284
  attemptedUrls
270
285
  );
271
286
 
272
- // If no token found, display error with attempted URLs
273
- validateAuthenticationResult(token, finalControllerUrl, lastError, controllerUrl, attemptedUrls);
287
+ if (!token || !finalControllerUrl) {
288
+ if (options.throwOnFailure) {
289
+ throwAuthFailureError(controllerUrl || finalControllerUrl, lastError, attemptedUrls);
290
+ }
291
+ validateAuthenticationResult(token, finalControllerUrl, lastError, controllerUrl, attemptedUrls);
292
+ }
274
293
 
275
294
  return {
276
295
  apiUrl: finalControllerUrl,
@@ -278,6 +297,9 @@ async function checkAuthentication(controllerUrl, _environment) {
278
297
  controllerUrl: finalControllerUrl
279
298
  };
280
299
  } catch (error) {
300
+ if (error.authFailure) {
301
+ throw error;
302
+ }
281
303
  displayAuthenticationError(error, { controllerUrl: controllerUrl });
282
304
  }
283
305
  }
@@ -106,6 +106,10 @@ function isDockerPermissionDeniedError(errorMsg) {
106
106
  */
107
107
  function formatDockerError(errorMsg) {
108
108
  if (isDockerImageNotFoundError(errorMsg)) {
109
+ // Preserve custom hint for template apps (keycloak, miso-controller) from up-miso/up-dataplane
110
+ if (errorMsg.includes('use --image') || errorMsg.includes('Pull the image')) {
111
+ return errorMsg.split('\n').map(line => (line.trim() ? ` ${line.trim()}` : ''));
112
+ }
109
113
  return [
110
114
  ' Docker image not found.',
111
115
  ' Run: aifabrix build <app> first'
@@ -326,6 +330,21 @@ function logOfflinePathWhenType(appPath, options) {
326
330
  logger.log(chalk.gray(`Using: ${displayPath}`));
327
331
  }
328
332
 
333
+ /**
334
+ * Returns true if the error is likely due to authentication failure (e.g. 401, token expired, login required).
335
+ * Used to show "Run: aifabrix login" for commands that require Controller auth (e.g. up-dataplane).
336
+ * @param {Error} error - The error that occurred
337
+ * @returns {boolean} True if the error appears to be auth-related
338
+ */
339
+ function isAuthenticationError(error) {
340
+ if (!error) return false;
341
+ if (error.authFailure === true) return true;
342
+ const msg = (error.message || '').toLowerCase();
343
+ const formatted = (typeof error.formatted === 'string' ? error.formatted : '').toLowerCase();
344
+ const combined = `${msg} ${formatted}`;
345
+ return /401|unauthorized|authentication|token expired|login required|aifabrix login|no authentication|device token|refresh token/.test(combined);
346
+ }
347
+
329
348
  /**
330
349
  * Handles command errors with user-friendly messages
331
350
  * @param {Error} error - The error that occurred
@@ -375,6 +394,7 @@ async function appendWizardError(appKey, error) {
375
394
  module.exports = {
376
395
  validateCommand,
377
396
  handleCommandError,
397
+ isAuthenticationError,
378
398
  appendWizardError,
379
399
  logOfflinePathWhenType
380
400
  };
@@ -18,58 +18,12 @@ const buildCopy = require('./build-copy');
18
18
  const { formatMissingDbPasswordError } = require('./error-formatter');
19
19
  const { getContainerPort } = require('./port-resolver');
20
20
  const { parseImageOverride } = require('./parse-image-ref');
21
+ const { registerComposeHelpers } = require('./compose-handlebars-helpers');
22
+ const { isVectorDatabaseName } = require('./compose-vector-helper');
23
+ const paths = require('./paths');
24
+ const { getInfraDirName } = require('../infrastructure/helpers');
21
25
 
22
- // Register commonly used helpers
23
- handlebars.registerHelper('eq', (a, b) => a === b);
24
-
25
- // Register Handlebars helper for quoting PostgreSQL identifiers
26
- // PostgreSQL requires identifiers with hyphens or special characters to be quoted
27
- handlebars.registerHelper('pgQuote', (identifier) => {
28
- if (!identifier) {
29
- return '';
30
- }
31
- // Always quote identifiers to handle hyphens and special characters
32
- // Return SafeString to prevent HTML escaping
33
- return new handlebars.SafeString(`"${String(identifier).replace(/"/g, '""')}"`);
34
- });
35
-
36
- // Helper to generate quoted PostgreSQL user name from database name
37
- // User names must use underscores (not hyphens) for PostgreSQL compatibility
38
- handlebars.registerHelper('pgUser', (dbName) => {
39
- if (!dbName) {
40
- return '';
41
- }
42
- // Replace hyphens with underscores in user name (database names can have hyphens, but user names should not)
43
- const userName = `${String(dbName).replace(/-/g, '_')}_user`;
44
- // Return SafeString to prevent HTML escaping
45
- return new handlebars.SafeString(`"${userName.replace(/"/g, '""')}"`);
46
- });
47
-
48
- // Helper to generate old user name format (for migration - drops old users with hyphens)
49
- // This is used to drop legacy users that were created with hyphens before the fix
50
- // Returns unquoted name (quotes should be added in template where needed)
51
- handlebars.registerHelper('pgUserOld', (dbName) => {
52
- if (!dbName) {
53
- return '';
54
- }
55
- // Old format: database name + _user (preserving hyphens)
56
- const userName = `${String(dbName)}_user`;
57
- // Return unquoted name - template will add quotes where needed
58
- return new handlebars.SafeString(userName);
59
- });
60
-
61
- // Helper to generate unquoted PostgreSQL user name (for SQL WHERE clauses)
62
- // Returns the user name without quotes for use in SQL queries
63
- handlebars.registerHelper('pgUserName', (dbName) => {
64
- if (!dbName) {
65
- return '';
66
- }
67
- // Replace hyphens with underscores in user name
68
- const userName = `${String(dbName).replace(/-/g, '_')}_user`;
69
- // Return unquoted name for SQL queries
70
- return new handlebars.SafeString(userName);
71
- });
72
-
26
+ registerComposeHelpers();
73
27
  /**
74
28
  * Loads and compiles Docker Compose template
75
29
  * @param {string} language - Language type
@@ -95,7 +49,6 @@ function loadDockerComposeTemplate(language) {
95
49
  const templateContent = fsSync.readFileSync(templatePath, 'utf8');
96
50
  return handlebars.compile(templateContent);
97
51
  }
98
-
99
52
  /**
100
53
  * Extracts image name from configuration (same logic as build.js)
101
54
  * @param {Object} config - Application configuration
@@ -112,7 +65,6 @@ function getImageName(config, appName) {
112
65
  }
113
66
  return appName;
114
67
  }
115
-
116
68
  /**
117
69
  * Builds app configuration section
118
70
  * @param {string} appName - Application name
@@ -125,7 +77,6 @@ function buildAppConfig(appName, config) {
125
77
  name: config.displayName || appName
126
78
  };
127
79
  }
128
-
129
80
  /**
130
81
  * Builds image configuration section
131
82
  * @param {Object} config - Application configuration
@@ -255,9 +206,6 @@ function buildServiceConfig(appName, config, port, devId, imageOverride) {
255
206
  port: containerPortValue, // Container port (for health check and template)
256
207
  containerPort: containerPortValue, // Container port (always set, equals containerPort if exists, else port)
257
208
  hostPort: hostPort, // Host port (options.port if provided, else config.port)
258
- build: {
259
- localPort: config.build?.localPort || null // Only used for .env file PORT variable, not for Docker Compose
260
- },
261
209
  healthCheck: buildHealthCheckConfig(config),
262
210
  traefik: buildTraefikConfig(config, devId),
263
211
  ...buildRequiresConfig(config)
@@ -445,6 +393,52 @@ async function readDatabasePasswordsIfNeeded(requiresDatabase, databases, envFil
445
393
  return { map: {}, array: [] };
446
394
  }
447
395
 
396
+ /**
397
+ * Resolves image override from options (--image, --tag, or null).
398
+ * @param {Object} options - Run options
399
+ * @param {Object} appConfig - Application configuration
400
+ * @param {string} appName - Application name
401
+ * @returns {string|null} Full image reference or null
402
+ */
403
+ function resolveImageOverride(options, appConfig, appName) {
404
+ if (options.image) return options.image;
405
+ if (options.imageOverride) return options.imageOverride;
406
+ if (options.tag) return `${getImageName(appConfig, appName)}:${options.tag}`;
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * Resolves Miso environment from options (tst, pro, or dev).
412
+ * @param {Object} options - Run options
413
+ * @returns {string} 'dev' | 'tst' | 'pro'
414
+ */
415
+ function resolveMisoEnvironment(options) {
416
+ const env = (options.env && typeof options.env === 'string') ? options.env.toLowerCase() : 'dev';
417
+ return (env === 'tst' || env === 'pro') ? env : 'dev';
418
+ }
419
+
420
+ /**
421
+ * Resolves dev mount path from options.
422
+ * @param {Object} options - Run options
423
+ * @returns {string|null} Trimmed path or null
424
+ */
425
+ function resolveDevMountPath(options) {
426
+ return (options.devMountPath && typeof options.devMountPath === 'string') ? options.devMountPath.trim() : null;
427
+ }
428
+
429
+ /**
430
+ * Resolves env file path from options or default dev dir (forward slashes).
431
+ * @param {Object} options - Run options
432
+ * @param {string} devDir - Default dev directory
433
+ * @returns {string} Absolute env file path
434
+ */
435
+ function resolveEnvFilePath(options, devDir) {
436
+ const envFilePath = (options.envFilePath && typeof options.envFilePath === 'string')
437
+ ? path.resolve(options.envFilePath)
438
+ : path.join(devDir, '.env');
439
+ return envFilePath.replace(/\\/g, '/');
440
+ }
441
+
448
442
  /**
449
443
  * Generates Docker Compose configuration from template
450
444
  * @async
@@ -458,8 +452,7 @@ async function generateDockerCompose(appName, appConfig, options) {
458
452
  const language = appConfig.build?.language || appConfig.language || 'typescript';
459
453
  const template = loadDockerComposeTemplate(language);
460
454
  const port = options.port || appConfig.port || 3000;
461
- const imageOverride = options.image || options.imageOverride ||
462
- (options.tag ? `${getImageName(appConfig, appName)}:${options.tag}` : null);
455
+ const imageOverride = resolveImageOverride(options, appConfig, appName);
463
456
  const { devId, idNum } = await getDeveloperIdAndNumeric();
464
457
  const { networkName, containerName } = buildNetworkAndContainerNames(appName, devId, idNum);
465
458
  const serviceConfig = buildServiceConfig(appName, appConfig, port, devId, imageOverride);
@@ -467,33 +460,41 @@ async function generateDockerCompose(appName, appConfig, options) {
467
460
  const networksConfig = buildNetworksConfig(appConfig);
468
461
 
469
462
  const devDir = buildCopy.getDevDirectory(appName, devId);
470
- const envFilePath = path.join(devDir, '.env');
471
- const envFileAbsolutePath = envFilePath.replace(/\\/g, '/');
463
+ const envFileAbsolutePath = resolveEnvFilePath(options, devDir);
464
+ const dbInitEnvFileAbsolutePath = (options.dbInitEnvFilePath && typeof options.dbInitEnvFilePath === 'string')
465
+ ? path.resolve(options.dbInitEnvFilePath).replace(/\\/g, '/')
466
+ : null;
472
467
 
473
468
  const databasePasswords = await readDatabasePasswordsIfNeeded(
474
469
  serviceConfig.requiresDatabase || false,
475
470
  networksConfig.databases || [],
476
- envFilePath,
471
+ envFileAbsolutePath,
477
472
  appName
478
473
  );
479
-
480
- const templateData = {
474
+ const devMountPath = resolveDevMountPath(options);
475
+ const reloadStartRaw = appConfig.build?.reloadStart;
476
+ const reloadStartCommand =
477
+ devMountPath && typeof reloadStartRaw === 'string' && reloadStartRaw.trim().length > 0
478
+ ? reloadStartRaw.trim()
479
+ : null;
480
+
481
+ const infraPgpassPath = path.join(paths.getAifabrixHome(), getInfraDirName(devId), 'pgpass');
482
+ const useInfraPgpass = serviceConfig.requiresDatabase && fsSync.existsSync(infraPgpassPath);
483
+ return template({
481
484
  ...serviceConfig,
482
485
  ...volumesConfig,
483
486
  ...networksConfig,
484
487
  envFile: envFileAbsolutePath,
488
+ dbInitEnvFile: dbInitEnvFileAbsolutePath,
485
489
  databasePasswords,
486
490
  devId: idNum,
487
491
  networkName,
488
- containerName
489
- };
490
- return template(templateData);
492
+ containerName,
493
+ misoEnvironment: resolveMisoEnvironment(options),
494
+ devMountPath,
495
+ reloadStartCommand,
496
+ infraPgpassPath: useInfraPgpass ? infraPgpassPath : null,
497
+ useInfraPgpass: !!useInfraPgpass
498
+ });
491
499
  }
492
-
493
- module.exports = {
494
- generateDockerCompose,
495
- getImageName,
496
- derivePathFromPattern,
497
- buildTraefikConfig,
498
- buildDevUsername
499
- };
500
+ module.exports = { generateDockerCompose, getImageName, derivePathFromPattern, buildTraefikConfig, buildDevUsername, isVectorDatabaseName };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Handlebars helpers for Docker Compose templates.
3
+ * @fileoverview Compose template helpers (pgQuote, pgUser, isVectorDatabase, etc.)
4
+ * @author AI Fabrix Team
5
+ * @version 2.0.0
6
+ */
7
+
8
+ const handlebars = require('handlebars');
9
+ const { isVectorDatabaseName } = require('./compose-vector-helper');
10
+
11
+ /**
12
+ * Registers Handlebars helpers used by Docker Compose templates.
13
+ */
14
+ function registerComposeHelpers() {
15
+ handlebars.registerHelper('eq', (a, b) => a === b);
16
+
17
+ handlebars.registerHelper('pgQuote', (identifier) => {
18
+ if (!identifier) return '';
19
+ return new handlebars.SafeString(`"${String(identifier).replace(/"/g, '""')}"`);
20
+ });
21
+
22
+ handlebars.registerHelper('pgUser', (dbName) => {
23
+ if (!dbName) return '';
24
+ const userName = `${String(dbName).replace(/-/g, '_')}_user`;
25
+ return new handlebars.SafeString(`"${userName.replace(/"/g, '""')}"`);
26
+ });
27
+
28
+ handlebars.registerHelper('pgUserOld', (dbName) => {
29
+ if (!dbName) return '';
30
+ const userName = `${String(dbName)}_user`;
31
+ return new handlebars.SafeString(userName);
32
+ });
33
+
34
+ handlebars.registerHelper('pgUserName', (dbName) => {
35
+ if (!dbName) return '';
36
+ const userName = `${String(dbName).replace(/-/g, '_')}_user`;
37
+ return new handlebars.SafeString(userName);
38
+ });
39
+
40
+ handlebars.registerHelper('isVectorDatabase', (name) => isVectorDatabaseName(name));
41
+ }
42
+
43
+ module.exports = { registerComposeHelpers };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Vector database name predicate for Docker Compose generation.
3
+ * Used so db-init can run CREATE EXTENSION vector on vector-store databases.
4
+ * @fileoverview Vector database name helper for compose-generator
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ /**
10
+ * Returns true when the database name ends with "vector" (case-insensitive).
11
+ * @param {string} name - Database name
12
+ * @returns {boolean}
13
+ */
14
+ function isVectorDatabaseName(name) {
15
+ return name !== null && name !== undefined && String(name).toLowerCase().endsWith('vector');
16
+ }
17
+
18
+ module.exports = { isVectorDatabaseName };
@@ -10,6 +10,21 @@
10
10
 
11
11
  const path = require('path');
12
12
 
13
+ /**
14
+ * GET /api/dev/settings response parameter names (builder-cli.md §1).
15
+ * Single source of truth so CLI and config merge stay aligned with the contract.
16
+ */
17
+ const SETTINGS_RESPONSE_KEYS = [
18
+ 'user-mutagen-folder',
19
+ 'secrets-encryption',
20
+ 'aifabrix-secrets',
21
+ 'aifabrix-env-config',
22
+ 'remote-server',
23
+ 'docker-endpoint',
24
+ 'sync-ssh-user',
25
+ 'sync-ssh-host'
26
+ ];
27
+
13
28
  /**
14
29
  * Get path configuration value
15
30
  * @async
@@ -73,6 +88,114 @@ function createEnvConfigPathFunctions(getConfigFn, saveConfigFn) {
73
88
  };
74
89
  }
75
90
 
91
+ function createRemoteConfigGetters(getConfigFn) {
92
+ return {
93
+ async getRemoteServer() {
94
+ return getPathConfig(getConfigFn, 'remote-server');
95
+ },
96
+ async getDockerEndpoint() {
97
+ return getPathConfig(getConfigFn, 'docker-endpoint');
98
+ },
99
+ async getUserMutagenFolder() {
100
+ return getPathConfig(getConfigFn, 'user-mutagen-folder');
101
+ },
102
+ async getSyncSshUser() {
103
+ return getPathConfig(getConfigFn, 'sync-ssh-user');
104
+ },
105
+ async getSyncSshHost() {
106
+ return getPathConfig(getConfigFn, 'sync-ssh-host');
107
+ }
108
+ };
109
+ }
110
+
111
+ function createRemoteConfigSetters(getConfigFn, saveConfigFn) {
112
+ return {
113
+ async setRemoteServer(value) {
114
+ if (value !== null && value !== undefined && typeof value !== 'string') {
115
+ throw new Error('remote-server must be a string');
116
+ }
117
+ const config = await getConfigFn();
118
+ config['remote-server'] = value ? value.trim().replace(/\/+$/, '') : undefined;
119
+ await saveConfigFn(config);
120
+ },
121
+ async setDockerEndpoint(value) {
122
+ if (value !== null && value !== undefined && typeof value !== 'string') {
123
+ throw new Error('docker-endpoint must be a string');
124
+ }
125
+ const config = await getConfigFn();
126
+ config['docker-endpoint'] = value || undefined;
127
+ await saveConfigFn(config);
128
+ }
129
+ };
130
+ }
131
+
132
+ function isHttpUrl(value) {
133
+ return typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'));
134
+ }
135
+
136
+ /**
137
+ * Derive hostname from a URL (e.g. https://builder.aifabrix.dev -> builder.aifabrix.dev).
138
+ * @param {string} url - URL string
139
+ * @returns {string|null} Hostname or null if invalid
140
+ */
141
+ function hostnameFromUrl(url) {
142
+ if (!url || typeof url !== 'string') return null;
143
+ const s = url.trim().replace(/\/+$/, '');
144
+ if (!s) return null;
145
+ const withProtocol = s.match(/^https?:\/\//) ? s : `https://${s}`;
146
+ try {
147
+ return new URL(withProtocol).hostname || null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function applySecretsUrlFromRemote(config) {
154
+ const remoteServer = config['remote-server'];
155
+ const secretsPath = config['aifabrix-secrets'];
156
+ if (!remoteServer || !secretsPath || isHttpUrl(secretsPath)) return;
157
+ const base = typeof remoteServer === 'string' ? remoteServer.trim().replace(/\/+$/, '') : '';
158
+ if (base) config['aifabrix-secrets'] = `${base}/api/dev/secrets`;
159
+ }
160
+
161
+ function applySyncAndDockerFromHost(config) {
162
+ const host = hostnameFromUrl(config['remote-server']);
163
+ if (!host) return;
164
+ if (!config['sync-ssh-host']) config['sync-ssh-host'] = host;
165
+ if (!config['docker-endpoint']) config['docker-endpoint'] = `tcp://${host}:2376`;
166
+ }
167
+
168
+ async function mergeRemoteSettingsImpl(getConfigFn, saveConfigFn, settings) {
169
+ if (!settings || typeof settings !== 'object') return;
170
+ const config = await getConfigFn();
171
+ for (const key of SETTINGS_RESPONSE_KEYS) {
172
+ const raw = settings[key];
173
+ if (raw === undefined || raw === null) continue;
174
+ const value = typeof raw === 'string' ? raw.trim() : raw;
175
+ if (value === '') continue;
176
+ config[key] = value;
177
+ }
178
+ applySecretsUrlFromRemote(config);
179
+ applySyncAndDockerFromHost(config);
180
+ await saveConfigFn(config);
181
+ }
182
+
183
+ /**
184
+ * Remote Docker / Builder Server config. Used when remote-server is set.
185
+ * @param {Function} getConfigFn - Function to get config
186
+ * @param {Function} saveConfigFn - Function to save config
187
+ * @returns {Object} Remote config getters, setters, and mergeRemoteSettings
188
+ */
189
+ function createRemoteConfigFunctions(getConfigFn, saveConfigFn) {
190
+ return {
191
+ ...createRemoteConfigGetters(getConfigFn),
192
+ ...createRemoteConfigSetters(getConfigFn, saveConfigFn),
193
+ async mergeRemoteSettings(settings) {
194
+ return mergeRemoteSettingsImpl(getConfigFn, saveConfigFn, settings);
195
+ }
196
+ };
197
+ }
198
+
76
199
  /**
77
200
  * Create path configuration functions with config access
78
201
  * @param {Function} getConfigFn - Function to get config
@@ -82,13 +205,15 @@ function createEnvConfigPathFunctions(getConfigFn, saveConfigFn) {
82
205
  function createPathConfigFunctions(getConfigFn, saveConfigFn) {
83
206
  return {
84
207
  ...createHomeAndSecretsPathFunctions(getConfigFn, saveConfigFn),
85
- ...createEnvConfigPathFunctions(getConfigFn, saveConfigFn)
208
+ ...createEnvConfigPathFunctions(getConfigFn, saveConfigFn),
209
+ ...createRemoteConfigFunctions(getConfigFn, saveConfigFn)
86
210
  };
87
211
  }
88
212
 
89
213
  module.exports = {
90
214
  getPathConfig,
91
215
  setPathConfig,
92
- createPathConfigFunctions
216
+ createPathConfigFunctions,
217
+ SETTINGS_RESPONSE_KEYS
93
218
  };
94
219