@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
@@ -15,6 +15,24 @@ const yaml = require('js-yaml');
15
15
  const logger = require('./logger');
16
16
  const pathsUtil = require('./paths');
17
17
  const { getContainerPort } = require('./port-resolver');
18
+ const { loadYamlTolerantOfDuplicateKeys } = require('./secrets-generator');
19
+
20
+ /**
21
+ * Parses secrets YAML content with fallback for duplicate keys.
22
+ * @param {string} content - Raw file content
23
+ * @returns {Object} Parsed secrets object
24
+ */
25
+ function parseSecretsContent(content) {
26
+ try {
27
+ return yaml.load(content);
28
+ } catch (yamlErr) {
29
+ const msg = yamlErr.message || '';
30
+ if (msg.includes('duplicate') || msg.includes('duplicated mapping')) {
31
+ return loadYamlTolerantOfDuplicateKeys(content);
32
+ }
33
+ throw yamlErr;
34
+ }
35
+ }
18
36
 
19
37
  /**
20
38
  * Loads secrets from file with cascading lookup support
@@ -45,6 +63,38 @@ async function loadSecretsFromFile(filePath) {
45
63
  }
46
64
  }
47
65
 
66
+ /**
67
+ * Loads user secrets from the primary config directory (AIFABRIX_HOME or ~/.aifabrix).
68
+ * Used as the master source when merging with project/public secrets: user values win,
69
+ * missing keys are filled from the public (aifabrix-secrets) file.
70
+ * Does not use config.yaml aifabrix-home so the merge always sees the actual user file.
71
+ *
72
+ * @function loadPrimaryUserSecrets
73
+ * @returns {Object} Loaded secrets object or empty object
74
+ */
75
+ function loadPrimaryUserSecrets() {
76
+ const primaryDir = pathsUtil.getConfigDirForPaths();
77
+ const userSecretsPath = path.join(primaryDir, 'secrets.local.yaml');
78
+ if (!fs.existsSync(userSecretsPath)) {
79
+ return {};
80
+ }
81
+
82
+ try {
83
+ const content = fs.readFileSync(userSecretsPath, 'utf8');
84
+ const secrets = parseSecretsContent(content);
85
+ if (!secrets || typeof secrets !== 'object') {
86
+ throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
87
+ }
88
+ return secrets;
89
+ } catch (error) {
90
+ if (error.message.includes('Invalid secrets file format')) {
91
+ throw error;
92
+ }
93
+ logger.warn(`Warning: Could not read secrets file ${userSecretsPath}: ${error.message}`);
94
+ return {};
95
+ }
96
+ }
97
+
48
98
  /**
49
99
  * Loads user secrets from ~/.aifabrix/secrets.local.yaml
50
100
  * Uses paths.getAifabrixHome() to respect config.yaml aifabrix-home override
@@ -59,7 +109,7 @@ function loadUserSecrets() {
59
109
 
60
110
  try {
61
111
  const content = fs.readFileSync(userSecretsPath, 'utf8');
62
- const secrets = yaml.load(content);
112
+ const secrets = parseSecretsContent(content);
63
113
  if (!secrets || typeof secrets !== 'object') {
64
114
  throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
65
115
  }
@@ -157,6 +207,7 @@ function resolveUrlPort(protocol, hostname, port, urlPath, hostnameToService) {
157
207
 
158
208
  module.exports = {
159
209
  loadSecretsFromFile,
210
+ loadPrimaryUserSecrets,
160
211
  loadUserSecrets,
161
212
  loadDefaultSecrets,
162
213
  buildHostnameToServiceMap,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * AI Fabrix Builder – Secrets file validation
3
+ *
4
+ * Validates secrets.local.yaml (or given path): valid YAML, flat key-value structure,
5
+ * and optional naming convention (*KeyVault suffix per keyvault.md).
6
+ *
7
+ * @fileoverview Secrets file validation for structure and naming
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+
16
+ /**
17
+ * Optional naming convention: keys should end with KeyVault or match known patterns.
18
+ * @param {string} key - Secret key
19
+ * @returns {boolean} True if key matches convention
20
+ */
21
+ function keyMatchesNamingConvention(key) {
22
+ if (!key || typeof key !== 'string') return false;
23
+ if (key.endsWith('KeyVault')) return true;
24
+ return /^[a-z0-9-_]+KeyVault$/i.test(key);
25
+ }
26
+
27
+ /**
28
+ * Validate that parsed secrets is a flat object (no nested objects for values).
29
+ * @param {*} parsed - Parsed YAML
30
+ * @param {boolean} checkNaming - Whether to check key naming
31
+ * @returns {string[]} Errors
32
+ */
33
+ function validateParsedSecrets(parsed, checkNaming) {
34
+ const errors = [];
35
+ if (parsed === null || parsed === undefined) return errors;
36
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
37
+ errors.push('Secrets file must be a flat key-value object (no nested objects or arrays)');
38
+ return errors;
39
+ }
40
+ for (const [key, value] of Object.entries(parsed)) {
41
+ if (typeof value !== 'string' && typeof value !== 'number' && value !== null && value !== undefined) {
42
+ if (typeof value === 'object') {
43
+ errors.push(`Key "${key}": secret values must be strings or scalars (no nested objects)`);
44
+ }
45
+ }
46
+ if (checkNaming && !keyMatchesNamingConvention(key)) {
47
+ errors.push(`Key "${key}": recommended format is *KeyVault (e.g. postgres-passwordKeyVault)`);
48
+ }
49
+ }
50
+ return errors;
51
+ }
52
+
53
+ /**
54
+ * Validate secrets file at path: YAML syntax, flat object, optional naming check.
55
+ *
56
+ * @param {string} filePath - Path to secrets file
57
+ * @param {Object} [options] - Options
58
+ * @param {boolean} [options.checkNaming=false] - Check key names against *KeyVault convention
59
+ * @returns {{ valid: boolean, errors: string[], path: string }}
60
+ */
61
+ function validateSecretsFile(filePath, options = {}) {
62
+ const checkNaming = Boolean(options.checkNaming);
63
+ if (!filePath || typeof filePath !== 'string') {
64
+ return { valid: false, errors: ['Path is required'], path: '' };
65
+ }
66
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
67
+ if (!fs.existsSync(resolvedPath)) {
68
+ return { valid: false, errors: [`File not found: ${resolvedPath}`], path: resolvedPath };
69
+ }
70
+ let parsed;
71
+ try {
72
+ const content = fs.readFileSync(resolvedPath, 'utf8');
73
+ parsed = yaml.load(content);
74
+ } catch (err) {
75
+ return { valid: false, errors: [`Invalid YAML: ${err.message}`], path: resolvedPath };
76
+ }
77
+ const errors = validateParsedSecrets(parsed, checkNaming);
78
+ return { valid: errors.length === 0, errors, path: resolvedPath };
79
+ }
80
+
81
+ module.exports = {
82
+ validateSecretsFile,
83
+ keyMatchesNamingConvention
84
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @fileoverview Locate or generate SSH key for Mutagen sync (Windows and Mac). Prefer ed25519.
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const { execSync } = require('child_process');
11
+
12
+ /**
13
+ * Default SSH directory (user's .ssh)
14
+ * @returns {string} Path to .ssh directory
15
+ */
16
+ function getDefaultSshDir() {
17
+ const home = os.homedir();
18
+ return path.join(home, '.ssh');
19
+ }
20
+
21
+ /**
22
+ * Path to default ed25519 public key
23
+ * @returns {string} Path to id_ed25519.pub
24
+ */
25
+ function getDefaultEd25519PublicKeyPath() {
26
+ return path.join(getDefaultSshDir(), 'id_ed25519.pub');
27
+ }
28
+
29
+ /**
30
+ * Path to default ed25519 private key
31
+ * @returns {string} Path to id_ed25519
32
+ */
33
+ function getDefaultEd25519PrivateKeyPath() {
34
+ return path.join(getDefaultSshDir(), 'id_ed25519');
35
+ }
36
+
37
+ /**
38
+ * Ensure .ssh directory exists
39
+ * @param {string} [sshDir] - SSH directory (default: user .ssh)
40
+ * @returns {string} Resolved SSH dir path
41
+ */
42
+ function ensureSshDir(sshDir) {
43
+ const dir = sshDir || getDefaultSshDir();
44
+ if (!fs.existsSync(dir)) {
45
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
46
+ }
47
+ return dir;
48
+ }
49
+
50
+ /**
51
+ * Generate ed25519 SSH key pair if it does not exist. Idempotent.
52
+ * @param {string} [privateKeyPath] - Path to private key (default: ~/.ssh/id_ed25519)
53
+ * @returns {string} Path to public key file
54
+ * @throws {Error} If ssh-keygen fails
55
+ */
56
+ function ensureEd25519Key(privateKeyPath) {
57
+ const privPath = privateKeyPath || getDefaultEd25519PrivateKeyPath();
58
+ const pubPath = privPath + '.pub';
59
+ if (fs.existsSync(pubPath) && fs.existsSync(privPath)) {
60
+ return pubPath;
61
+ }
62
+ ensureSshDir(path.dirname(privPath));
63
+ execSync(`ssh-keygen -t ed25519 -f "${privPath}" -N "" -C "aifabrix"`, {
64
+ stdio: 'pipe',
65
+ encoding: 'utf8'
66
+ });
67
+ return pubPath;
68
+ }
69
+
70
+ /**
71
+ * Read public key content (single line). Prefer ed25519, fallback to id_rsa.pub.
72
+ * @param {string} [sshDir] - SSH directory
73
+ * @returns {string} Public key line (e.g. "ssh-ed25519 AAAA... aifabrix")
74
+ * @throws {Error} If no key found or read fails
75
+ */
76
+ function readPublicKeyContent(sshDir) {
77
+ const dir = sshDir || getDefaultSshDir();
78
+ const ed25519Pub = path.join(dir, 'id_ed25519.pub');
79
+ const rsaPub = path.join(dir, 'id_rsa.pub');
80
+ let pathToRead = null;
81
+ if (fs.existsSync(ed25519Pub)) {
82
+ pathToRead = ed25519Pub;
83
+ } else if (fs.existsSync(rsaPub)) {
84
+ pathToRead = rsaPub;
85
+ }
86
+ if (!pathToRead) {
87
+ throw new Error(
88
+ 'No SSH public key found. Run: ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "aifabrix"'
89
+ );
90
+ }
91
+ const content = fs.readFileSync(pathToRead, 'utf8').trim();
92
+ const firstLine = content.split('\n')[0];
93
+ if (!firstLine || !firstLine.startsWith('ssh-')) {
94
+ throw new Error(`Invalid SSH public key file: ${pathToRead}`);
95
+ }
96
+ return firstLine;
97
+ }
98
+
99
+ /**
100
+ * Get or create ed25519 key and return its public key content for POST /api/dev/users/:id/ssh-keys.
101
+ * @returns {string} Single-line public key content
102
+ */
103
+ function getOrCreatePublicKeyContent() {
104
+ ensureEd25519Key();
105
+ return readPublicKeyContent();
106
+ }
107
+
108
+ module.exports = {
109
+ getDefaultSshDir,
110
+ getDefaultEd25519PublicKeyPath,
111
+ getDefaultEd25519PrivateKeyPath,
112
+ ensureSshDir,
113
+ ensureEd25519Key,
114
+ readPublicKeyContent,
115
+ getOrCreatePublicKeyContent
116
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Token refresh failure message formatting and once-per-URL warning (used by token-manager).
3
+ * @fileoverview Token manager message helpers
4
+ * @author AI Fabrix Team
5
+ * @version 2.0.0
6
+ */
7
+
8
+ const config = require('../core/config');
9
+ const logger = require('./logger');
10
+
11
+ /** Network-style error messages that indicate controller unreachable (not token expiry). */
12
+ const NETWORK_ERROR_PATTERNS = [
13
+ 'fetch failed',
14
+ 'econnrefused',
15
+ 'enotfound',
16
+ 'etimedout',
17
+ 'network',
18
+ 'unreachable',
19
+ 'timed out'
20
+ ];
21
+
22
+ /** Controller URLs we have already logged a refresh-failure warning for this process. */
23
+ const refreshFailureWarnedUrls = new Set();
24
+
25
+ /** Controller URLs we have already logged a refresh-token-expired warning for this process. */
26
+ const refreshTokenExpiredWarnedUrls = new Set();
27
+
28
+ /**
29
+ * Reset warned state (for test isolation only). Not for production use.
30
+ */
31
+ function resetRefreshWarnedUrlsForTesting() {
32
+ refreshFailureWarnedUrls.clear();
33
+ refreshTokenExpiredWarnedUrls.clear();
34
+ }
35
+
36
+ /**
37
+ * Log "refresh token expired" once per controller URL per process (avoids duplicate messages when auth is tried multiple times).
38
+ * @param {string} controllerUrl - Controller URL (for dedupe key)
39
+ * @param {string} errorMessage - Full error message to log
40
+ */
41
+ function warnRefreshTokenExpiredOnce(controllerUrl, errorMessage) {
42
+ const key = (controllerUrl && typeof controllerUrl === 'string' && controllerUrl.trim())
43
+ ? config.normalizeControllerUrl(controllerUrl)
44
+ : '__no_url__';
45
+ if (refreshTokenExpiredWarnedUrls.has(key)) {
46
+ return;
47
+ }
48
+ refreshTokenExpiredWarnedUrls.add(key);
49
+ logger.warn(`Refresh token expired: ${errorMessage}`);
50
+ }
51
+
52
+ /**
53
+ * Returns a user-facing message for token refresh failure; adds a hint when the error looks like a connectivity issue.
54
+ * @param {string} errorMessage - Raw error message
55
+ * @param {string} [controllerUrl] - Controller URL for the hint
56
+ * @returns {string} Message to log
57
+ */
58
+ function formatRefreshFailureMessage(errorMessage, controllerUrl) {
59
+ const lower = (errorMessage || '').toLowerCase();
60
+ const isNetwork = NETWORK_ERROR_PATTERNS.some(p => lower.includes(p));
61
+ const hint = isNetwork
62
+ ? (controllerUrl
63
+ ? ` The controller at ${controllerUrl} may be unreachable—ensure it is running and try again, or run 'aifabrix login' once it is available.`
64
+ : ' The controller may be unreachable—ensure it is running and try again, or run \'aifabrix login\' once it is available.')
65
+ : '';
66
+ return `${errorMessage}${hint}`;
67
+ }
68
+
69
+ /**
70
+ * Log device token refresh failure once per controller URL per process.
71
+ * @param {string} controllerUrl - Controller URL (for dedupe key and message)
72
+ * @param {string} errorMessage - Raw error message
73
+ */
74
+ function warnRefreshFailureOnce(controllerUrl, errorMessage) {
75
+ const key = (controllerUrl && typeof controllerUrl === 'string' && controllerUrl.trim())
76
+ ? config.normalizeControllerUrl(controllerUrl)
77
+ : '__no_url__';
78
+ if (refreshFailureWarnedUrls.has(key)) {
79
+ return;
80
+ }
81
+ refreshFailureWarnedUrls.add(key);
82
+ logger.warn(`Failed to refresh device token: ${formatRefreshFailureMessage(errorMessage, controllerUrl)}`);
83
+ }
84
+
85
+ module.exports = {
86
+ formatRefreshFailureMessage,
87
+ warnRefreshFailureOnce,
88
+ warnRefreshTokenExpiredOnce,
89
+ resetRefreshWarnedUrlsForTesting
90
+ };
@@ -19,6 +19,7 @@ const {
19
19
  refreshClientToken,
20
20
  refreshDeviceToken
21
21
  } = require('./token-manager-refresh');
22
+ const { warnRefreshFailureOnce, warnRefreshTokenExpiredOnce } = require('./token-manager-messages');
22
23
 
23
24
  function getSecretsFilePath() {
24
25
  return path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
@@ -185,9 +186,9 @@ async function getOrRefreshDeviceToken(controllerUrl) {
185
186
  // Refresh failed - check if it's a refresh token expiry
186
187
  const errorMessage = error.message || String(error);
187
188
  if (errorMessage.includes('Refresh token has expired')) {
188
- logger.warn(`Refresh token expired: ${errorMessage}`);
189
+ warnRefreshTokenExpiredOnce(controllerUrl, errorMessage);
189
190
  } else {
190
- logger.warn(`Failed to refresh device token: ${errorMessage}`);
191
+ warnRefreshFailureOnce(controllerUrl, errorMessage);
191
192
  }
192
193
  return null;
193
194
  }
@@ -398,9 +399,9 @@ async function forceRefreshDeviceToken(controllerUrl) {
398
399
  } catch (error) {
399
400
  const errorMessage = error.message || String(error);
400
401
  if (errorMessage.includes('Refresh token has expired')) {
401
- logger.warn(`Refresh token expired: ${errorMessage}`);
402
+ warnRefreshTokenExpiredOnce(controllerUrl, errorMessage);
402
403
  } else {
403
- logger.warn(`Failed to refresh device token: ${errorMessage}`);
404
+ warnRefreshFailureOnce(controllerUrl, errorMessage);
404
405
  }
405
406
  return null;
406
407
  }
@@ -181,9 +181,6 @@ function validateBuildConfig(build) {
181
181
  if (build.envOutputPath) {
182
182
  buildConfig.envOutputPath = build.envOutputPath;
183
183
  }
184
- if (build.localPort) {
185
- buildConfig.localPort = build.localPort;
186
- }
187
184
  if (build.language) {
188
185
  buildConfig.language = build.language;
189
186
  }
@@ -193,6 +190,9 @@ function validateBuildConfig(build) {
193
190
  if (build.dockerfile && build.dockerfile.trim() !== '') {
194
191
  buildConfig.dockerfile = build.dockerfile;
195
192
  }
193
+ if (build.localPort !== undefined && build.localPort !== null) {
194
+ buildConfig.localPort = build.localPort;
195
+ }
196
196
 
197
197
  return Object.keys(buildConfig).length > 0 ? buildConfig : null;
198
198
  }
@@ -365,6 +365,69 @@ function validateDeploymentJson(deployment) {
365
365
  };
366
366
  }
367
367
 
368
+ /** Pattern matching ${VAR} style unresolved variables in strings */
369
+ const UNRESOLVED_VAR_REGEX = /\$\{[^}]+\}/g;
370
+
371
+ /**
372
+ * Recursively finds all string values in obj that contain ${...} placeholders.
373
+ * Used to ensure deployment manifest has no unresolved variables before deploy.
374
+ *
375
+ * @function findUnresolvedVariablesInObject
376
+ * @param {Object} obj - Object to scan (e.g. deployment manifest)
377
+ * @param {string} [prefix=''] - Path prefix for error reporting
378
+ * @returns {string[]} List of paths with example placeholder (e.g. "port: ${PORT}")
379
+ *
380
+ * @example
381
+ * findUnresolvedVariablesInObject({ port: '${PORT}' }) // ['port: ${PORT}']
382
+ */
383
+ function findUnresolvedVariablesInObject(obj, prefix = '') {
384
+ const found = [];
385
+ if (obj === null || obj === undefined) {
386
+ return found;
387
+ }
388
+ if (typeof obj === 'string') {
389
+ const matches = obj.match(UNRESOLVED_VAR_REGEX);
390
+ if (matches && matches.length > 0) {
391
+ const pathLabel = prefix || 'value';
392
+ found.push(`${pathLabel}: ${matches[0]}`);
393
+ }
394
+ return found;
395
+ }
396
+ if (Array.isArray(obj)) {
397
+ obj.forEach((item, i) => {
398
+ found.push(...findUnresolvedVariablesInObject(item, `${prefix}[${i}]`));
399
+ });
400
+ return found;
401
+ }
402
+ if (typeof obj === 'object') {
403
+ for (const [key, value] of Object.entries(obj)) {
404
+ const path = prefix ? `${prefix}.${key}` : key;
405
+ found.push(...findUnresolvedVariablesInObject(value, path));
406
+ }
407
+ return found;
408
+ }
409
+ return found;
410
+ }
411
+
412
+ /**
413
+ * Validates that deployment manifest contains no unresolved ${...} variables.
414
+ * Throws if any are found, with a message to use secret variables or literal values.
415
+ *
416
+ * @function validateNoUnresolvedVariablesInDeployment
417
+ * @param {Object} deployment - Deployment manifest object
418
+ * @throws {Error} If any ${...} placeholders are found
419
+ */
420
+ function validateNoUnresolvedVariablesInDeployment(deployment) {
421
+ const unresolved = findUnresolvedVariablesInObject(deployment);
422
+ if (unresolved.length > 0) {
423
+ const examples = [...new Set(unresolved)].slice(0, 5).join(', ');
424
+ throw new Error(
425
+ `Deployment manifest contains unresolved variables (e.g. ${examples}). ` +
426
+ 'Use secret variables (kv://) in env.template for sensitive values, and set the application port as a number in application.yaml.'
427
+ );
428
+ }
429
+ }
430
+
368
431
  /**
369
432
  * Validates all application configuration files
370
433
  * Runs complete validation suite for an application
@@ -411,6 +474,8 @@ module.exports = {
411
474
  validateRbac,
412
475
  validateEnvTemplate,
413
476
  validateDeploymentJson,
477
+ validateNoUnresolvedVariablesInDeployment,
478
+ findUnresolvedVariablesInObject,
414
479
  validateObjectAgainstApplicationSchema,
415
480
  checkEnvironment,
416
481
  formatValidationErrors,
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.40.2",
3
+ "version": "2.41.0",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
7
7
  "aifabrix": "bin/aifabrix.js",
8
- "aifx": "bin/aifabrix.js"
8
+ "af": "bin/aifabrix.js"
9
9
  },
10
10
  "scripts": {
11
11
  "test": "node tests/scripts/test-wrapper.js",
@@ -97,6 +97,39 @@ function displaySuccessMessage(currentVersion, newVersion) {
97
97
  console.log('Run "aifabrix --version" to verify.');
98
98
  }
99
99
 
100
+ /**
101
+ * Run pnpm link --global and npm link from project root (handles pnpm global bin not set).
102
+ * @param {string} projectRoot - Path to project root
103
+ * @returns {void}
104
+ * @throws {Error} If linking fails when pnpm global bin is not configured
105
+ */
106
+ function runPnpmLink(projectRoot) {
107
+ let pnpmLinked = false;
108
+ try {
109
+ execSync('pnpm link --global', { stdio: 'inherit', cwd: projectRoot });
110
+ pnpmLinked = true;
111
+ } catch (pnpmErr) {
112
+ const msg = (pnpmErr.message || String(pnpmErr));
113
+ if (msg.includes('global bin directory') || msg.includes('ERR_PNPM_NO_GLOBAL_BIN_DIR')) {
114
+ console.log(
115
+ '⚠️ pnpm global bin is not set up. Run "pnpm setup" and add PNPM_HOME to PATH, or we will use npm link.\n'
116
+ );
117
+ } else {
118
+ throw pnpmErr;
119
+ }
120
+ }
121
+ try {
122
+ execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
123
+ } catch {
124
+ if (!pnpmLinked) {
125
+ console.error(
126
+ '\n💡 To fix: run "pnpm setup" and add the suggested line to your shell config, then run install:local again.'
127
+ );
128
+ throw new Error('Linking failed. pnpm global bin not configured and npm link failed.');
129
+ }
130
+ }
131
+ }
132
+
100
133
  /**
101
134
  * Install local package globally
102
135
  * @returns {void}
@@ -107,31 +140,17 @@ function installLocal() {
107
140
  const currentVersion = getCurrentVersion();
108
141
 
109
142
  console.log(`Detected package manager: ${pm}\n`);
110
-
111
- // Show version comparison
112
143
  displayVersionInfo(currentVersion, packageVersion);
113
-
114
144
  console.log('Linking @aifabrix/builder globally...\n');
115
145
 
116
146
  try {
117
147
  const projectRoot = path.join(__dirname, '..');
118
148
  if (pm === 'pnpm') {
119
- // Update pnpm global.
120
- execSync('pnpm link --global', { stdio: 'inherit', cwd: projectRoot });
121
- // Also run npm link so npm's global bin points here; often PATH has
122
- // npm's global bin before pnpm's, so "aifabrix" would otherwise stay old.
123
- try {
124
- execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
125
- } catch {
126
- // npm may not be available or may fail; pnpm link already ran
127
- }
149
+ runPnpmLink(projectRoot);
128
150
  } else {
129
151
  execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
130
152
  }
131
-
132
- // Get new version after linking
133
153
  const newVersion = getCurrentVersion();
134
-
135
154
  displaySuccessMessage(currentVersion, newVersion);
136
155
  } catch (error) {
137
156
  console.error('\n❌ Failed to link package:', error.message);
@@ -70,7 +70,6 @@ Extra workflow steps are located in `templates/github/steps/`. When you use `--g
70
70
  - `{{databases}}` - Array of database configurations
71
71
 
72
72
  ### Build Configuration
73
- - `{{build.localPort}}` - Local development port (different from Docker port)
74
73
  - `{{mountVolume}}` - Volume mount path for local development
75
74
 
76
75
  ## Usage
@@ -38,7 +38,7 @@ aifabrix resolve {{appName}}
38
38
  aifabrix run {{appName}}
39
39
  ```
40
40
 
41
- **Access your app:** http://localhost:{{localPort}}
41
+ **Access your app:** http://localhost:{{port}}
42
42
 
43
43
  **View logs:**
44
44
  ```bash
@@ -118,7 +118,7 @@ aifabrix build {{appName}} --language typescript # Override language detection
118
118
  ### Run Options
119
119
 
120
120
  ```bash
121
- aifabrix run {{appName}} --port {{localPort}} # Override local port
121
+ aifabrix run {{appName}} --port {{port}} # Override port
122
122
  aifabrix run {{appName}} --debug # Debug output
123
123
  ```
124
124
 
@@ -166,7 +166,7 @@ Controller URL and environment (for `deploy`, `app register`, etc.) are set via
166
166
 
167
167
  - **"Docker not running"** → Start Docker Desktop
168
168
  - **"Not logged in"** → Run `aifabrix login` first
169
- - **"Port already in use"** → Use `aifabrix run {{appName}} --port <port>` or set `build.localPort` in `application.yaml` (default: {{localPort}})
169
+ - **"Port already in use"** → Use `aifabrix run {{appName}} --port <port>` or set `port` in `application.yaml` (default: {{port}})
170
170
  - **"Authentication failed"** → Run `aifabrix login` again
171
171
  - **"Build fails"** → Check Docker is running and `aifabrix-secrets` in `config.yaml` is configured correctly
172
172
  - **"Can't connect"** → Verify infrastructure is running{{#if hasDatabase}} and PostgreSQL is accessible{{/if}}
@@ -203,4 +203,4 @@ aifabrix json {{appName}}
203
203
 
204
204
  ---
205
205
 
206
- **Application**: {{appName}} | **Port**: {{localPort}} | **Registry**: {{registry}} | **Image**: {{imageName}}:latest
206
+ **Application**: {{appName}} | **Port**: {{port}} | **Registry**: {{registry}} | **Image**: {{imageName}}:latest
@@ -48,11 +48,12 @@ authentication:
48
48
  # Build Configuration
49
49
  # Dataplane builds from published image; context is project root (like miso-controller)
50
50
  build:
51
- context: ../.. # Docker build context (relative to builder/dataplane/)
51
+ context: ../.. # Docker build context (relative to builder/dataplane/)
52
52
  dockerfile: builder/dataplane/Dockerfile # Dockerfile path (relative to project root)
53
- envOutputPath: ../../.env # Copy to repo root for local dev
54
- localPort: 3011 # Port for local development (different from Docker port)
55
- language: python # Runtime language for template selection (typescript or python)
53
+ envOutputPath: ../../.env # Copy to repo root for local dev
54
+ localPort: 3011 # Port for local development (different from Docker port)
55
+ language: python # Runtime language for template selection (typescript or python)
56
+ reloadStart: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-3001} --reload # PORT set from port above at run time; default 3001 must match port
56
57
 
57
58
  # =============================================================================
58
59
  # Portal Input Configuration (Deployment Wizard)