@aifabrix/builder 2.39.0 → 2.39.2

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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Deploy status display helpers: build app URL from controller/port and show after deploy.
3
+ * @fileoverview Status URL display for application deployment
4
+ * @author AI Fabrix Team
5
+ * @version 2.0.0
6
+ */
7
+
8
+ const chalk = require('chalk');
9
+ const logger = require('../utils/logger');
10
+ const { getApplicationStatus } = require('../api/applications.api');
11
+
12
+ /**
13
+ * Builds app URL from controller base URL and app port (e.g. http://localhost:3600 + 3601 -> http://localhost:3601).
14
+ * @param {string} controllerUrl - Controller base URL
15
+ * @param {number} port - Application port
16
+ * @returns {string|null} App URL or null if parsing fails
17
+ */
18
+ function buildAppUrlFromControllerAndPort(controllerUrl, port) {
19
+ if (!controllerUrl || (port === null || port === undefined) || typeof port !== 'number') return null;
20
+ try {
21
+ const u = new URL(controllerUrl);
22
+ u.port = String(port);
23
+ return u.toString();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Parses URL and port from application status response body.
31
+ * @param {Object} body - Response body (may have data wrapper or top-level url/port)
32
+ * @returns {{ url: string|null, port: number|null }}
33
+ */
34
+ function parseUrlAndPortFromStatusBody(body) {
35
+ const data = body?.data ?? body;
36
+ const url = (data && typeof data.url === 'string' && data.url.trim() !== '')
37
+ ? data.url
38
+ : (body?.url && typeof body.url === 'string' && body.url.trim() !== '')
39
+ ? body.url
40
+ : null;
41
+ const port = (data && typeof data.port === 'number')
42
+ ? data.port
43
+ : (body && typeof body.port === 'number')
44
+ ? body.port
45
+ : null;
46
+ return { url, port };
47
+ }
48
+
49
+ /**
50
+ * Fetches app URL from controller application status and displays it.
51
+ * Uses status API url when present; otherwise derives from status port and controller host.
52
+ * @param {string} controllerUrl - Controller base URL (used only to derive host when status returns port)
53
+ * @param {string} envKey - Environment key
54
+ * @param {string} appKey - Application key (manifest.key)
55
+ * @param {Object} authConfig - Auth used for deployment (same as for status)
56
+ */
57
+ async function displayAppUrlFromController(controllerUrl, envKey, appKey, authConfig) {
58
+ let url = null;
59
+ let port = null;
60
+ try {
61
+ const res = await getApplicationStatus(controllerUrl, envKey, appKey, authConfig);
62
+ const parsed = parseUrlAndPortFromStatusBody(res?.data);
63
+ url = parsed.url;
64
+ port = parsed.port;
65
+ } catch (_) {
66
+ // Show fallback message below
67
+ }
68
+ if (!url && (port !== null && port !== undefined)) {
69
+ url = buildAppUrlFromControllerAndPort(controllerUrl, port);
70
+ }
71
+ if (url) {
72
+ logger.log(chalk.green(` ✓ App running at ${url}`));
73
+ } else {
74
+ logger.log(chalk.blue(' ✓ App deployed. Get URL from controller dashboard.'));
75
+ }
76
+ }
77
+
78
+ module.exports = { displayAppUrlFromController, buildAppUrlFromControllerAndPort, parseUrlAndPortFromStatusBody };
package/lib/app/deploy.js CHANGED
@@ -17,8 +17,8 @@ const pushUtils = require('../deployment/push');
17
17
  const logger = require('../utils/logger');
18
18
  const { detectAppType, getBuilderPath, getIntegrationPath } = require('../utils/paths');
19
19
  const { checkApplicationExists } = require('../utils/app-existence');
20
- const { getApplicationStatus } = require('../api/applications.api');
21
20
  const { loadDeploymentConfig } = require('./deploy-config');
21
+ const { displayAppUrlFromController } = require('./deploy-status-display');
22
22
 
23
23
  /**
24
24
  * Validate application name format
@@ -219,26 +219,6 @@ function displayDeploymentResults(result) {
219
219
  }
220
220
  }
221
221
 
222
- /**
223
- * Fetches app URL from controller application status and displays it.
224
- * On API failure or missing URL, shows controllerUrl so the user always sees where the app is.
225
- * @param {string} controllerUrl - Controller base URL (used as fallback)
226
- * @param {string} envKey - Environment key
227
- * @param {string} appKey - Application key (manifest.key)
228
- * @param {Object} authConfig - Auth used for deployment (same as for status)
229
- */
230
- async function displayAppUrlFromController(controllerUrl, envKey, appKey, authConfig) {
231
- let url = null;
232
- try {
233
- const res = await getApplicationStatus(controllerUrl, envKey, appKey, authConfig);
234
- const body = res?.data;
235
- url = (body && (body.url || body.data?.url)) || res?.url || null;
236
- } catch (_) {
237
- // Use controllerUrl fallback below
238
- }
239
- logger.log(chalk.green(` ✓ App running at ${url || controllerUrl}`));
240
- }
241
-
242
222
  /**
243
223
  * Check if app is external and handle external deployment.
244
224
  * When options.type === 'external', forces deployment from integration/<app> (no app register needed).
package/lib/app/readme.js CHANGED
@@ -12,6 +12,7 @@ const fs = require('fs').promises;
12
12
  const fsSync = require('fs');
13
13
  const path = require('path');
14
14
  const handlebars = require('handlebars');
15
+ const yaml = require('js-yaml');
15
16
  const { generateExternalReadmeContent } = require('../utils/external-readme');
16
17
 
17
18
  /**
@@ -158,28 +159,69 @@ function generateReadmeMd(appName, config) {
158
159
  }
159
160
 
160
161
  /**
161
- * Generates README.md file if it doesn't exist
162
+ * Generates README.md file (optionally only if missing)
162
163
  * @async
163
164
  * @function generateReadmeMdFile
164
165
  * @param {string} appPath - Path to application directory
165
166
  * @param {string} appName - Application name
166
- * @param {Object} config - Application configuration
167
+ * @param {Object} config - Application configuration (e.g. from variables.yaml)
168
+ * @param {Object} [options] - Options
169
+ * @param {boolean} [options.force] - If true, overwrite existing README.md (dynamic generation)
167
170
  * @returns {Promise<void>} Resolves when README.md is generated or skipped
168
171
  * @throws {Error} If file generation fails
169
172
  */
170
- async function generateReadmeMdFile(appPath, appName, config) {
171
- // Ensure directory exists
173
+ async function generateReadmeMdFile(appPath, appName, config, options = {}) {
172
174
  await fs.mkdir(appPath, { recursive: true });
173
175
  const readmePath = path.join(appPath, 'README.md');
174
- if (!(await fileExists(readmePath))) {
175
- const readmeContent = generateReadmeMd(appName, config);
176
- await fs.writeFile(readmePath, readmeContent, 'utf8');
176
+ if (!options.force && (await fileExists(readmePath))) {
177
+ return;
178
+ }
179
+ const readmeContent = generateReadmeMd(appName, config);
180
+ await fs.writeFile(readmePath, readmeContent, 'utf8');
181
+ }
182
+
183
+ /**
184
+ * Loads variables.yaml from app path and generates README.md (overwrites if present).
185
+ * Used when copying template apps or running up-miso / up-platform / up-dataplane.
186
+ * @async
187
+ * @function ensureReadmeForAppPath
188
+ * @param {string} appPath - Path to application directory (must contain variables.yaml)
189
+ * @param {string} appName - Application name
190
+ * @returns {Promise<void>} Resolves when README.md is written or skipped (no variables.yaml)
191
+ */
192
+ async function ensureReadmeForAppPath(appPath, appName) {
193
+ const variablesPath = path.join(appPath, 'variables.yaml');
194
+ if (!(await fileExists(variablesPath))) {
195
+ return;
196
+ }
197
+ const content = await fs.readFile(variablesPath, 'utf8');
198
+ const config = yaml.load(content) || {};
199
+ await generateReadmeMdFile(appPath, appName, config, { force: true });
200
+ }
201
+
202
+ /**
203
+ * Generates README.md for an app at builder path(s): primary and cwd/builder if different.
204
+ * Use after ensureAppFromTemplate in up-miso / up-dataplane so README reflects current config.
205
+ * @async
206
+ * @function ensureReadmeForApp
207
+ * @param {string} appName - Application name (e.g. keycloak, miso-controller, dataplane)
208
+ * @returns {Promise<void>}
209
+ */
210
+ async function ensureReadmeForApp(appName) {
211
+ const pathsUtil = require('../utils/paths');
212
+ const primaryPath = pathsUtil.getBuilderPath(appName);
213
+ await ensureReadmeForAppPath(primaryPath, appName);
214
+ const cwdBuilderPath = path.join(process.cwd(), 'builder', appName);
215
+ if (path.resolve(cwdBuilderPath) !== path.resolve(primaryPath)) {
216
+ await ensureReadmeForAppPath(cwdBuilderPath, appName);
177
217
  }
178
218
  }
179
219
 
180
220
  module.exports = {
181
221
  generateReadmeMdFile,
182
222
  generateReadmeMd,
183
- formatAppDisplayName
223
+ formatAppDisplayName,
224
+ ensureReadmeForAppPath,
225
+ ensureReadmeForApp
184
226
  };
185
227
 
@@ -19,17 +19,26 @@ const { runServiceUserCreate } = require('../commands/service-user');
19
19
  function setupServiceUserCommands(program) {
20
20
  const serviceUser = program
21
21
  .command('service-user')
22
- .description('Create service users for integrations (one-time secret on create)');
22
+ .description('Create and manage service users (API clients) for integrations and CI')
23
+ .addHelpText('after', `
24
+ Service users are dedicated accounts for integrations, CI pipelines, or API clients.
25
+ The controller returns a one-time clientSecret on create—save it immediately; it cannot be retrieved again.
26
+
27
+ Example:
28
+ $ aifabrix service-user create -u api-client-001 -e api@example.com \\
29
+ --redirect-uris "https://app.example.com/callback" --group-names "AI-Fabrix-Developers"
30
+
31
+ Required: permission service-user:create on the controller. Run "aifabrix login" first.`);
23
32
 
24
33
  serviceUser
25
34
  .command('create')
26
- .description('Create a service user (username, email, redirectUris, groupIds); receive one-time clientSecret (save it now)')
27
- .option('--controller <url>', 'Controller URL (default: from config)')
35
+ .description('Create a service user and receive a one-time clientSecret (save it now; it will not be shown again)')
36
+ .option('--controller <url>', 'Controller base URL (default: from config)')
28
37
  .option('-u, --username <username>', 'Service user username (required)')
29
38
  .option('-e, --email <email>', 'Email address (required)')
30
- .option('--redirect-uris <uris>', 'Comma-separated redirect URIs for OAuth2 (required, e.g. https://app.example.com/callback)')
31
- .option('--group-names <names>', 'Comma-separated group names (required, e.g. AI-Fabrix-Developers)')
32
- .option('-d, --description <description>', 'Optional description')
39
+ .option('--redirect-uris <uris>', 'Comma-separated OAuth2 redirect URIs (required)')
40
+ .option('--group-names <names>', 'Comma-separated group names to assign (required)')
41
+ .option('-d, --description <description>', 'Description for the service user')
33
42
  .action(async(options) => {
34
43
  try {
35
44
  const opts = {
@@ -36,16 +36,22 @@ async function getServiceUserAuth(controllerUrl) {
36
36
  }
37
37
 
38
38
  /**
39
- * Extract clientId and clientSecret from API response (response may be wrapped in data)
40
- * @param {Object} response - API response
39
+ * Extract clientId and clientSecret from API response.
40
+ * Controller returns { data: { user, clientSecret } }; API client puts body in response.data.
41
+ * So payload is at response.data.data. clientId may be on user.clientId or user.federatedIdentity.keycloakClientId.
42
+ * @param {Object} response - API response (success: true, data: body)
41
43
  * @returns {{ clientId: string, clientSecret: string }}
42
44
  */
43
45
  function extractCreateResponse(response) {
44
- const data = response?.data ?? response;
45
- return {
46
- clientId: data?.clientId ?? '',
47
- clientSecret: data?.clientSecret ?? ''
48
- };
46
+ const payload = response?.data?.data ?? response?.data ?? response;
47
+ const user = payload?.user;
48
+ const clientId =
49
+ user?.clientId ??
50
+ user?.federatedIdentity?.keycloakClientId ??
51
+ payload?.clientId ??
52
+ '';
53
+ const clientSecret = payload?.clientSecret ?? '';
54
+ return { clientId, clientSecret };
49
55
  }
50
56
 
51
57
  /**
@@ -15,9 +15,11 @@ const chalk = require('chalk');
15
15
  const logger = require('../utils/logger');
16
16
  const pathsUtil = require('../utils/paths');
17
17
  const { copyTemplateFiles } = require('../validation/template');
18
+ const { ensureReadmeForAppPath, ensureReadmeForApp } = require('../app/readme');
18
19
 
19
20
  /**
20
21
  * Copy template to a target path if variables.yaml is missing there.
22
+ * After copy, generates README.md from templates/applications/README.md.hbs.
21
23
  * @param {string} appName - Application name
22
24
  * @param {string} targetAppPath - Target directory (e.g. builder/keycloak)
23
25
  * @returns {Promise<boolean>} True if template was copied, false if already present
@@ -28,6 +30,7 @@ async function ensureTemplateAtPath(appName, targetAppPath) {
28
30
  return false;
29
31
  }
30
32
  await copyTemplateFiles(appName, targetAppPath);
33
+ await ensureReadmeForAppPath(targetAppPath, appName);
31
34
  return true;
32
35
  }
33
36
 
@@ -160,6 +163,7 @@ async function ensureAppFromTemplate(appName) {
160
163
  }
161
164
  }
162
165
 
166
+ await ensureReadmeForApp(appName);
163
167
  return primaryCopied;
164
168
  }
165
169
 
@@ -35,13 +35,19 @@ async function generateExternalSystemTemplate(appPath, systemKey, config) {
35
35
  const templateContent = await fs.readFile(templatePath, 'utf8');
36
36
  const template = handlebars.compile(templateContent);
37
37
 
38
+ const roles = (config.roles || null) && config.roles.map((role) => ({
39
+ ...role,
40
+ groups: role.groups || role.Groups || undefined
41
+ }));
42
+
38
43
  const context = {
39
44
  systemKey: systemKey,
40
45
  systemDisplayName: config.systemDisplayName || systemKey.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
41
46
  systemDescription: config.systemDescription || `External system integration for ${systemKey}`,
42
47
  systemType: config.systemType || 'openapi',
43
48
  authType: config.authType || 'apikey',
44
- roles: config.roles || null,
49
+ baseUrl: config.baseUrl || null,
50
+ roles: roles || null,
45
51
  permissions: config.permissions || null
46
52
  };
47
53
 
@@ -74,6 +80,8 @@ async function generateExternalDataSourceTemplate(appPath, datasourceKey, config
74
80
  const templateContent = await fs.readFile(templatePath, 'utf8');
75
81
  const template = handlebars.compile(templateContent);
76
82
 
83
+ const dimensions = config.dimensions || {};
84
+ const attributes = config.attributes || {};
77
85
  const context = {
78
86
  datasourceKey: datasourceKey,
79
87
  datasourceDisplayName: config.datasourceDisplayName || datasourceKey.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
@@ -82,8 +90,11 @@ async function generateExternalDataSourceTemplate(appPath, datasourceKey, config
82
90
  entityType: config.entityType || datasourceKey.split('-').pop(),
83
91
  resourceType: config.resourceType || 'document',
84
92
  systemType: config.systemType || 'openapi',
85
- dimensions: config.dimensions || {},
86
- attributes: config.attributes || {}
93
+ // Pass non-empty objects so template uses custom block; empty/null so template uses schema-valid defaults
94
+ dimensions: Object.keys(dimensions).length > 0 ? dimensions : null,
95
+ attributes: Object.keys(attributes).length > 0 ? attributes : null,
96
+ // Literal expression strings for default attribute block (schema: pipe-based DSL {{raw.path}})
97
+ raw: { id: '{{raw.id}}', name: '{{raw.name}}' }
87
98
  };
88
99
 
89
100
  const rendered = template(context);