@aifabrix/builder 2.22.2 → 2.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/jest.config.coverage.js +37 -0
  2. package/lib/api/pipeline.api.js +10 -9
  3. package/lib/app-deploy.js +36 -14
  4. package/lib/app-list.js +191 -71
  5. package/lib/app-prompts.js +77 -26
  6. package/lib/app-readme.js +123 -5
  7. package/lib/app-rotate-secret.js +210 -80
  8. package/lib/app-run-helpers.js +200 -172
  9. package/lib/app-run.js +137 -68
  10. package/lib/audit-logger.js +8 -7
  11. package/lib/build.js +161 -250
  12. package/lib/cli.js +73 -65
  13. package/lib/commands/login.js +45 -31
  14. package/lib/commands/logout.js +181 -0
  15. package/lib/commands/secure.js +59 -24
  16. package/lib/config.js +79 -45
  17. package/lib/datasource-deploy.js +89 -29
  18. package/lib/deployer.js +164 -129
  19. package/lib/diff.js +63 -21
  20. package/lib/environment-deploy.js +36 -19
  21. package/lib/external-system-deploy.js +134 -66
  22. package/lib/external-system-download.js +244 -171
  23. package/lib/external-system-test.js +199 -164
  24. package/lib/generator-external.js +145 -72
  25. package/lib/generator-helpers.js +49 -17
  26. package/lib/generator-split.js +105 -58
  27. package/lib/infra.js +101 -131
  28. package/lib/schema/application-schema.json +895 -896
  29. package/lib/schema/env-config.yaml +11 -4
  30. package/lib/template-validator.js +13 -4
  31. package/lib/utils/api.js +8 -8
  32. package/lib/utils/app-register-auth.js +36 -18
  33. package/lib/utils/app-run-containers.js +140 -0
  34. package/lib/utils/auth-headers.js +6 -6
  35. package/lib/utils/build-copy.js +60 -2
  36. package/lib/utils/build-helpers.js +94 -0
  37. package/lib/utils/cli-utils.js +177 -76
  38. package/lib/utils/compose-generator.js +12 -2
  39. package/lib/utils/config-tokens.js +151 -9
  40. package/lib/utils/deployment-errors.js +137 -69
  41. package/lib/utils/deployment-validation-helpers.js +103 -0
  42. package/lib/utils/docker-build.js +57 -0
  43. package/lib/utils/dockerfile-utils.js +13 -3
  44. package/lib/utils/env-copy.js +163 -94
  45. package/lib/utils/env-map.js +226 -86
  46. package/lib/utils/error-formatters/network-errors.js +0 -1
  47. package/lib/utils/external-system-display.js +14 -19
  48. package/lib/utils/external-system-env-helpers.js +107 -0
  49. package/lib/utils/external-system-test-helpers.js +144 -0
  50. package/lib/utils/health-check.js +10 -8
  51. package/lib/utils/infra-status.js +123 -0
  52. package/lib/utils/paths.js +228 -49
  53. package/lib/utils/schema-loader.js +125 -57
  54. package/lib/utils/token-manager.js +3 -3
  55. package/lib/utils/yaml-preserve.js +55 -16
  56. package/lib/validate.js +87 -89
  57. package/package.json +7 -5
  58. package/scripts/ci-fix.sh +19 -0
  59. package/scripts/ci-simulate.sh +19 -0
  60. package/scripts/install-local.js +210 -0
  61. package/templates/applications/miso-controller/test.yaml +1 -0
  62. package/templates/python/Dockerfile.hbs +8 -45
  63. package/templates/typescript/Dockerfile.hbs +8 -42
package/lib/app-readme.js CHANGED
@@ -44,11 +44,21 @@ function formatAppDisplayName(appName) {
44
44
  * Loads and compiles README.md template
45
45
  * @returns {Function} Compiled Handlebars template
46
46
  * @throws {Error} If template not found
47
+ * @private
47
48
  */
48
- function loadReadmeTemplate() {
49
- const templatePath = path.join(__dirname, '..', 'templates', 'applications', 'README.md.hbs');
49
+ function _loadReadmeTemplate() {
50
+ // Use getProjectRoot to reliably find templates in all environments
51
+ const { getProjectRoot } = require('./utils/paths');
52
+ const projectRoot = getProjectRoot();
53
+ const templatePath = path.join(projectRoot, 'templates', 'applications', 'README.md.hbs');
54
+
50
55
  if (!fsSync.existsSync(templatePath)) {
51
- throw new Error(`README template not found at ${templatePath}`);
56
+ // Provide helpful error message with actual paths checked
57
+ const errorMessage = `README template not found at ${templatePath}\n` +
58
+ ` Project root: ${projectRoot}\n` +
59
+ ` Templates directory: ${path.join(projectRoot, 'templates', 'applications')}\n` +
60
+ ` Global PROJECT_ROOT: ${typeof global !== 'undefined' && global.PROJECT_ROOT ? global.PROJECT_ROOT : 'not set'}`;
61
+ throw new Error(errorMessage);
52
62
  }
53
63
 
54
64
  const templateContent = fsSync.readFileSync(templatePath, 'utf8');
@@ -62,7 +72,6 @@ function loadReadmeTemplate() {
62
72
  * @returns {string} README.md content
63
73
  */
64
74
  function generateReadmeMd(appName, config) {
65
- const template = loadReadmeTemplate();
66
75
  const displayName = formatAppDisplayName(appName);
67
76
  const imageName = `aifabrix/${appName}`;
68
77
  const port = config.port || 3000;
@@ -88,7 +97,116 @@ function generateReadmeMd(appName, config) {
88
97
  hasAnyService
89
98
  };
90
99
 
91
- return template(context);
100
+ // Always generate comprehensive README programmatically to ensure consistency
101
+ // regardless of template file content
102
+ return generateComprehensiveReadme(context);
103
+ }
104
+
105
+ /**
106
+ * Generates comprehensive README.md content programmatically
107
+ * @param {Object} context - Template context
108
+ * @returns {string} Comprehensive README.md content
109
+ */
110
+ function generateComprehensiveReadme(context) {
111
+ const { appName, displayName, imageName, port, registry, hasDatabase, hasRedis, hasStorage, hasAuthentication, hasAnyService } = context;
112
+
113
+ let prerequisites = 'Before running this application, ensure the following prerequisites are met:\n';
114
+ prerequisites += '- `@aifabrix/builder` installed globally\n';
115
+ prerequisites += '- Docker Desktop running\n';
116
+
117
+ if (hasAnyService) {
118
+ if (hasDatabase) {
119
+ prerequisites += '- PostgreSQL database\n';
120
+ }
121
+ if (hasRedis) {
122
+ prerequisites += '- Redis\n';
123
+ }
124
+ if (hasStorage) {
125
+ prerequisites += '- File storage configured\n';
126
+ }
127
+ if (hasAuthentication) {
128
+ prerequisites += '- Authentication/RBAC configured\n';
129
+ }
130
+ } else {
131
+ prerequisites += '- Infrastructure running\n';
132
+ }
133
+
134
+ let troubleshooting = '';
135
+ if (hasDatabase) {
136
+ troubleshooting = `### Database Connection Issues
137
+
138
+ If you encounter database connection errors, ensure:
139
+ - PostgreSQL is running and accessible
140
+ - Database credentials are correctly configured in your \`.env\` file
141
+ - The database name matches your configuration
142
+ - Verify infrastructure is running and PostgreSQL is accessible`;
143
+ } else {
144
+ troubleshooting = 'Verify infrastructure is running.';
145
+ }
146
+
147
+ return `# ${displayName} Builder
148
+
149
+ Build, run, and deploy ${displayName}.
150
+
151
+ ## Prerequisites
152
+
153
+ ${prerequisites}
154
+
155
+ ## Quick Start
156
+
157
+ ### 1. Install
158
+
159
+ Install the AI Fabrix Builder CLI if you haven't already.
160
+
161
+ ### 2. Configure
162
+
163
+ Configure your application settings in \`variables.yaml\`.
164
+
165
+ ### 3. Build & Run Locally
166
+
167
+ Build the application:
168
+ \`\`\`bash
169
+ aifabrix build ${appName}
170
+ \`\`\`
171
+
172
+ Run the application:
173
+ \`\`\`bash
174
+ aifabrix run ${appName}
175
+ \`\`\`
176
+
177
+ The application will be available at http://localhost:${port} (default: ${port}).
178
+
179
+ ### 4. Deploy to Azure
180
+
181
+ Push to registry:
182
+ \`\`\`bash
183
+ aifabrix push ${appName} --registry ${registry} --tag "v1.0.0,latest"
184
+ \`\`\`
185
+
186
+ ## Configuration
187
+
188
+ - **Port**: ${port} (default: 3000)
189
+ - **Image**: ${imageName}:latest
190
+ - **Registry**: ${registry}
191
+
192
+ ## Docker Commands
193
+
194
+ View logs:
195
+ \`\`\`bash
196
+ docker logs aifabrix-${appName} -f
197
+ \`\`\`
198
+
199
+ Stop the application:
200
+ \`\`\`bash
201
+ aifabrix down ${appName}
202
+ \`\`\`
203
+
204
+ ## Troubleshooting
205
+
206
+ ${troubleshooting}
207
+
208
+ For more information, see the [AI Fabrix Builder documentation](https://docs.aifabrix.com).
209
+ `;
92
210
  }
93
211
 
94
212
  /**
@@ -20,6 +20,36 @@ const { updateEnvTemplate } = require('./utils/env-template');
20
20
  const { getEnvironmentPrefix } = require('./app-register');
21
21
  const { generateEnvFile } = require('./secrets');
22
22
 
23
+ /**
24
+ * Find device token from config by trying each stored URL
25
+ * @async
26
+ * @param {Object} deviceConfig - Device configuration object
27
+ * @returns {Promise<Object|null>} Token result with token and controllerUrl, or null if not found
28
+ */
29
+ async function findDeviceTokenFromConfig(deviceConfig) {
30
+ const deviceUrls = Object.keys(deviceConfig);
31
+ if (deviceUrls.length === 0) {
32
+ return null;
33
+ }
34
+
35
+ for (const storedUrl of deviceUrls) {
36
+ try {
37
+ const normalizedStoredUrl = normalizeControllerUrl(storedUrl);
38
+ const deviceToken = await getOrRefreshDeviceToken(normalizedStoredUrl);
39
+ if (deviceToken && deviceToken.token) {
40
+ return {
41
+ token: deviceToken.token,
42
+ controllerUrl: deviceToken.controller || normalizedStoredUrl
43
+ };
44
+ }
45
+ } catch (error) {
46
+ // Continue to next URL
47
+ }
48
+ }
49
+
50
+ return null;
51
+ }
52
+
23
53
  /**
24
54
  * Validate environment parameter
25
55
  * @param {string} environment - Environment ID or key
@@ -31,20 +61,83 @@ function validateEnvironment(environment) {
31
61
  }
32
62
  }
33
63
 
64
+ /**
65
+ * Validate credentials object structure
66
+ * @param {Object} credentials - Credentials object to validate
67
+ * @returns {boolean} True if credentials are valid
68
+ */
69
+ function isValidCredentials(credentials) {
70
+ return credentials &&
71
+ typeof credentials === 'object' &&
72
+ typeof credentials.clientId === 'string' &&
73
+ typeof credentials.clientSecret === 'string';
74
+ }
75
+
76
+ /**
77
+ * Extract credentials from API response
78
+ * Handles multiple response formats:
79
+ * 1. Direct format: { success: true, data: { credentials: {...} } }
80
+ * 2. Wrapped format: { success: true, data: { success: true, data: { credentials: {...} } } }
81
+ * @param {Object} response - API response from centralized API client
82
+ * @returns {Object|null} Object with credentials and message, or null if not found
83
+ */
84
+ function extractCredentials(response) {
85
+ // Note: response.data is already validated in validateResponse
86
+ const apiResponse = response.data;
87
+
88
+ // Try wrapped format first: response.data.data.credentials
89
+ if (apiResponse.data && apiResponse.data.credentials) {
90
+ const credentials = apiResponse.data.credentials;
91
+ if (isValidCredentials(credentials)) {
92
+ return {
93
+ credentials: credentials,
94
+ message: apiResponse.data.message || apiResponse.message
95
+ };
96
+ }
97
+ }
98
+
99
+ // Try direct format: response.data.credentials
100
+ if (apiResponse.credentials) {
101
+ const credentials = apiResponse.credentials;
102
+ if (isValidCredentials(credentials)) {
103
+ return {
104
+ credentials: credentials,
105
+ message: apiResponse.message
106
+ };
107
+ }
108
+ }
109
+
110
+ return null;
111
+ }
112
+
34
113
  /**
35
114
  * Validate API response structure
36
115
  * @param {Object} response - API response
116
+ * @returns {Object} Object with credentials and message
37
117
  * @throws {Error} If response structure is invalid
38
118
  */
39
119
  function validateResponse(response) {
40
120
  if (!response.data || typeof response.data !== 'object') {
121
+ logger.error(chalk.red('❌ Invalid response: missing data'));
122
+ logger.error(chalk.gray('\nAPI response type:'), typeof response.data);
123
+ logger.error(chalk.gray('API response:'), JSON.stringify(response.data, null, 2));
124
+ logger.error(chalk.gray('\nFull response for debugging:'));
125
+ logger.error(chalk.gray(JSON.stringify(response, null, 2)));
41
126
  throw new Error('Invalid response: missing data');
42
127
  }
43
128
 
44
- const credentials = response.data.credentials;
45
- if (!credentials || typeof credentials !== 'object' || typeof credentials.clientId !== 'string' || typeof credentials.clientSecret !== 'string') {
129
+ const result = extractCredentials(response);
130
+
131
+ if (!result) {
132
+ logger.error(chalk.red('❌ Invalid response: missing or invalid credentials'));
133
+ logger.error(chalk.gray('\nAPI response type:'), typeof response.data);
134
+ logger.error(chalk.gray('API response:'), JSON.stringify(response.data, null, 2));
135
+ logger.error(chalk.gray('\nFull response for debugging:'));
136
+ logger.error(chalk.gray(JSON.stringify(response, null, 2)));
46
137
  throw new Error('Invalid response: missing or invalid credentials');
47
138
  }
139
+
140
+ return result;
48
141
  }
49
142
 
50
143
  /**
@@ -79,69 +172,135 @@ function displayRotationResults(appKey, environment, credentials, apiUrl, messag
79
172
  }
80
173
 
81
174
  /**
82
- * Rotate secret for an application
175
+ * Get device token from provided controller URL
83
176
  * @async
84
- * @param {string} appKey - Application key
85
- * @param {Object} options - Command options
86
- * @param {string} options.environment - Environment ID or key
87
- * @param {string} [options.controller] - Controller URL (optional, uses configured controller if not provided)
88
- * @throws {Error} If rotation fails
177
+ * @param {string} controllerUrl - Controller URL
178
+ * @returns {Promise<Object|null>} Object with token and controllerUrl, or null if failed
89
179
  */
90
- async function rotateSecret(appKey, options) {
91
- logger.log(chalk.yellow('⚠️ This will invalidate the old ClientSecret!\n'));
180
+ async function getTokenFromUrl(controllerUrl) {
181
+ try {
182
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
183
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
184
+ if (deviceToken && deviceToken.token) {
185
+ return {
186
+ token: deviceToken.token,
187
+ controllerUrl: deviceToken.controller || normalizedUrl
188
+ };
189
+ }
190
+ } catch (error) {
191
+ logger.error(chalk.red(`❌ Failed to authenticate with controller: ${controllerUrl}`));
192
+ logger.error(chalk.gray(`Error: ${error.message}`));
193
+ process.exit(1);
194
+ }
195
+ return null;
196
+ }
92
197
 
93
- const config = await getConfig();
198
+ /**
199
+ * Validate and handle missing authentication token
200
+ * @param {string|null} token - Authentication token
201
+ * @param {string|null} controllerUrl - Controller URL
202
+ * @param {string} [providedUrl] - Original provided URL for error context
203
+ */
204
+ function validateAuthToken(token, controllerUrl, providedUrl) {
205
+ if (!token || !controllerUrl) {
206
+ const formattedError = formatAuthenticationError({
207
+ controllerUrl: providedUrl || undefined,
208
+ message: 'No valid authentication found'
209
+ });
210
+ logger.error(formattedError);
211
+ process.exit(1);
212
+ }
213
+ }
94
214
 
95
- // Get controller URL with priority: options.controller > device tokens
96
- const controllerUrl = options.controller || null;
215
+ /**
216
+ * Get authentication token for rotation
217
+ * @async
218
+ * @param {string} [controllerUrl] - Optional controller URL
219
+ * @param {Object} config - Configuration object
220
+ * @returns {Promise<Object>} Object with token and actualControllerUrl
221
+ * @throws {Error} If authentication fails
222
+ */
223
+ async function getRotationAuthToken(controllerUrl, config) {
97
224
  let token = null;
98
225
  let actualControllerUrl = null;
99
226
 
100
227
  // If controller URL provided, try to get device token
101
228
  if (controllerUrl) {
102
- try {
103
- const normalizedUrl = normalizeControllerUrl(controllerUrl);
104
- const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
105
- if (deviceToken && deviceToken.token) {
106
- token = deviceToken.token;
107
- actualControllerUrl = deviceToken.controller || normalizedUrl;
108
- }
109
- } catch (error) {
110
- // Show which controller URL failed
111
- logger.error(chalk.red(`❌ Failed to authenticate with controller: ${controllerUrl}`));
112
- logger.error(chalk.gray(`Error: ${error.message}`));
113
- process.exit(1);
229
+ const tokenResult = await getTokenFromUrl(controllerUrl);
230
+ if (tokenResult) {
231
+ token = tokenResult.token;
232
+ actualControllerUrl = tokenResult.controllerUrl;
114
233
  }
115
234
  }
116
235
 
117
236
  // If no token yet, try to find any device token in config
118
237
  if (!token && config.device) {
119
- const deviceUrls = Object.keys(config.device);
120
- if (deviceUrls.length > 0) {
121
- for (const storedUrl of deviceUrls) {
122
- try {
123
- const normalizedStoredUrl = normalizeControllerUrl(storedUrl);
124
- const deviceToken = await getOrRefreshDeviceToken(normalizedStoredUrl);
125
- if (deviceToken && deviceToken.token) {
126
- token = deviceToken.token;
127
- actualControllerUrl = deviceToken.controller || normalizedStoredUrl;
128
- break;
129
- }
130
- } catch (error) {
131
- // Continue to next URL
132
- }
133
- }
238
+ const tokenResult = await findDeviceTokenFromConfig(config.device);
239
+ if (tokenResult) {
240
+ token = tokenResult.token;
241
+ actualControllerUrl = tokenResult.controllerUrl;
134
242
  }
135
243
  }
136
244
 
137
- if (!token || !actualControllerUrl) {
138
- const formattedError = formatAuthenticationError({
139
- controllerUrl: controllerUrl || undefined,
140
- message: 'No valid authentication found'
141
- });
142
- logger.error(formattedError);
143
- process.exit(1);
245
+ validateAuthToken(token, actualControllerUrl, controllerUrl);
246
+ return { token, actualControllerUrl };
247
+ }
248
+
249
+ /**
250
+ * Save credentials locally and update env files
251
+ * @async
252
+ * @param {string} appKey - Application key
253
+ * @param {Object} credentials - Credentials object
254
+ * @param {string} actualControllerUrl - Controller URL
255
+ * @throws {Error} If saving fails
256
+ */
257
+ async function saveCredentialsLocally(appKey, credentials, actualControllerUrl) {
258
+ const clientIdKey = `${appKey}-client-idKeyVault`;
259
+ const clientSecretKey = `${appKey}-client-secretKeyVault`;
260
+
261
+ try {
262
+ await saveLocalSecret(clientIdKey, credentials.clientId);
263
+ await saveLocalSecret(clientSecretKey, credentials.clientSecret);
264
+
265
+ // Update env.template if localhost
266
+ if (isLocalhost(actualControllerUrl)) {
267
+ await updateEnvTemplate(appKey, clientIdKey, clientSecretKey, actualControllerUrl);
268
+
269
+ // Regenerate .env file with updated credentials
270
+ try {
271
+ await generateEnvFile(appKey, null, 'local');
272
+ logger.log(chalk.green('✓ .env file updated with new credentials'));
273
+ } catch (error) {
274
+ logger.warn(chalk.yellow(`⚠️ Could not regenerate .env file: ${error.message}`));
275
+ }
276
+
277
+ logger.log(chalk.green('\n✓ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
278
+ logger.log(chalk.green('✓ env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
279
+ } else {
280
+ logger.log(chalk.green('\n✓ Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
281
+ }
282
+ } catch (error) {
283
+ logger.warn(chalk.yellow(`⚠️ Could not save credentials locally: ${error.message}`));
144
284
  }
285
+ }
286
+
287
+ /**
288
+ * Rotate secret for an application
289
+ * @async
290
+ * @param {string} appKey - Application key
291
+ * @param {Object} options - Command options
292
+ * @param {string} options.environment - Environment ID or key
293
+ * @param {string} [options.controller] - Controller URL (optional, uses configured controller if not provided)
294
+ * @throws {Error} If rotation fails
295
+ */
296
+ async function rotateSecret(appKey, options) {
297
+ logger.log(chalk.yellow('⚠️ This will invalidate the old ClientSecret!\n'));
298
+
299
+ const config = await getConfig();
300
+
301
+ // Get authentication token
302
+ const controllerUrl = options.controller || null;
303
+ const { token, actualControllerUrl } = await getRotationAuthToken(controllerUrl, config);
145
304
 
146
305
  // Validate environment
147
306
  validateEnvironment(options.environment);
@@ -158,40 +317,11 @@ async function rotateSecret(appKey, options) {
158
317
  process.exit(1);
159
318
  }
160
319
 
161
- // Validate response structure
162
- validateResponse(response);
320
+ // Validate response structure and extract credentials
321
+ const { credentials, message } = validateResponse(response);
163
322
 
164
- const credentials = response.data.credentials;
165
- const message = response.data.message;
166
-
167
- // Save credentials to local secrets (always save when rotating)
168
- const clientIdKey = `${appKey}-client-idKeyVault`;
169
- const clientSecretKey = `${appKey}-client-secretKeyVault`;
170
-
171
- try {
172
- await saveLocalSecret(clientIdKey, credentials.clientId);
173
- await saveLocalSecret(clientSecretKey, credentials.clientSecret);
174
-
175
- // Update env.template if localhost
176
- if (isLocalhost(actualControllerUrl)) {
177
- await updateEnvTemplate(appKey, clientIdKey, clientSecretKey, actualControllerUrl);
178
-
179
- // Regenerate .env file with updated credentials
180
- try {
181
- await generateEnvFile(appKey, null, 'local');
182
- logger.log(chalk.green('✓ .env file updated with new credentials'));
183
- } catch (error) {
184
- logger.warn(chalk.yellow(`⚠️ Could not regenerate .env file: ${error.message}`));
185
- }
186
-
187
- logger.log(chalk.green('\n✓ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
188
- logger.log(chalk.green('✓ env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
189
- } else {
190
- logger.log(chalk.green('\n✓ Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
191
- }
192
- } catch (error) {
193
- logger.warn(chalk.yellow(`⚠️ Could not save credentials locally: ${error.message}`));
194
- }
323
+ // Save credentials locally
324
+ await saveCredentialsLocally(appKey, credentials, actualControllerUrl);
195
325
 
196
326
  // Display results
197
327
  displayRotationResults(appKey, options.environment, credentials, actualControllerUrl, message);