@aifabrix/builder 2.37.9 → 2.39.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 (74) hide show
  1. package/.cursor/rules/project-rules.mdc +3 -0
  2. package/README.md +19 -0
  3. package/integration/hubspot/hubspot-deploy.json +1 -5
  4. package/integration/hubspot/hubspot-system.json +0 -3
  5. package/lib/api/applications.api.js +29 -1
  6. package/lib/api/auth.api.js +14 -0
  7. package/lib/api/credentials.api.js +34 -0
  8. package/lib/api/datasources-core.api.js +16 -1
  9. package/lib/api/datasources-extended.api.js +18 -1
  10. package/lib/api/deployments.api.js +32 -0
  11. package/lib/api/environments.api.js +11 -0
  12. package/lib/api/external-systems.api.js +16 -1
  13. package/lib/api/pipeline.api.js +12 -4
  14. package/lib/api/service-users.api.js +41 -0
  15. package/lib/api/types/applications.types.js +1 -1
  16. package/lib/api/types/deployments.types.js +1 -1
  17. package/lib/api/types/pipeline.types.js +1 -1
  18. package/lib/api/types/service-users.types.js +24 -0
  19. package/lib/api/wizard.api.js +40 -1
  20. package/lib/app/deploy.js +86 -21
  21. package/lib/app/rotate-secret.js +3 -1
  22. package/lib/app/run-helpers.js +35 -2
  23. package/lib/app/show-display.js +30 -11
  24. package/lib/app/show.js +34 -8
  25. package/lib/cli/index.js +4 -0
  26. package/lib/cli/setup-app.js +40 -0
  27. package/lib/cli/setup-credential-deployment.js +72 -0
  28. package/lib/cli/setup-infra.js +3 -3
  29. package/lib/cli/setup-service-user.js +52 -0
  30. package/lib/cli/setup-utility.js +1 -25
  31. package/lib/commands/app-down.js +80 -0
  32. package/lib/commands/app-logs.js +146 -0
  33. package/lib/commands/app.js +24 -1
  34. package/lib/commands/credential-list.js +104 -0
  35. package/lib/commands/deployment-list.js +184 -0
  36. package/lib/commands/service-user.js +193 -0
  37. package/lib/commands/up-common.js +74 -5
  38. package/lib/commands/up-dataplane.js +13 -7
  39. package/lib/commands/up-miso.js +17 -24
  40. package/lib/core/templates.js +2 -2
  41. package/lib/external-system/deploy.js +79 -15
  42. package/lib/generator/builders.js +8 -27
  43. package/lib/generator/external-controller-manifest.js +5 -4
  44. package/lib/generator/index.js +16 -14
  45. package/lib/generator/split.js +1 -0
  46. package/lib/generator/wizard.js +4 -1
  47. package/lib/schema/application-schema.json +6 -14
  48. package/lib/schema/deployment-rules.yaml +121 -0
  49. package/lib/schema/external-system.schema.json +0 -16
  50. package/lib/utils/app-register-config.js +10 -12
  51. package/lib/utils/app-run-containers.js +2 -1
  52. package/lib/utils/compose-generator.js +2 -1
  53. package/lib/utils/deployment-errors.js +10 -0
  54. package/lib/utils/environment-checker.js +25 -6
  55. package/lib/utils/help-builder.js +0 -1
  56. package/lib/utils/image-version.js +209 -0
  57. package/lib/utils/schema-loader.js +1 -1
  58. package/lib/utils/variable-transformer.js +7 -33
  59. package/lib/validation/external-manifest-validator.js +1 -1
  60. package/package.json +1 -1
  61. package/templates/applications/README.md.hbs +1 -3
  62. package/templates/applications/dataplane/Dockerfile +2 -2
  63. package/templates/applications/dataplane/README.md +20 -6
  64. package/templates/applications/dataplane/env.template +31 -2
  65. package/templates/applications/dataplane/rbac.yaml +1 -1
  66. package/templates/applications/dataplane/variables.yaml +7 -4
  67. package/templates/applications/keycloak/Dockerfile +3 -3
  68. package/templates/applications/keycloak/README.md +14 -4
  69. package/templates/applications/keycloak/env.template +17 -2
  70. package/templates/applications/keycloak/variables.yaml +2 -1
  71. package/templates/applications/miso-controller/README.md +1 -3
  72. package/templates/applications/miso-controller/env.template +85 -25
  73. package/templates/applications/miso-controller/rbac.yaml +15 -0
  74. package/templates/applications/miso-controller/variables.yaml +24 -23
@@ -0,0 +1,146 @@
1
+ /**
2
+ * App logs command – show container env (masked) and docker logs for an app
3
+ *
4
+ * @fileoverview App logs command implementation
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const chalk = require('chalk');
10
+ const { exec, spawn } = require('child_process');
11
+ const { promisify } = require('util');
12
+ const logger = require('../utils/logger');
13
+ const config = require('../core/config');
14
+ const containerHelpers = require('../utils/app-run-containers');
15
+ const { validateAppName } = require('../app/push');
16
+
17
+ const execAsync = promisify(exec);
18
+
19
+ /** Default number of log lines */
20
+ const DEFAULT_TAIL_LINES = 100;
21
+
22
+ /** Env key patterns that indicate a secret (mask value) */
23
+ const SECRET_KEY_PATTERN = /password|secret|token|credential|api[_-]?key/i;
24
+
25
+ /** Prefixes to strip before checking key (avoids masking KEYCLOAK_SERVER_URL etc.) */
26
+ const KEY_PREFIXES_TO_STRIP = /^KEYCLOAK_|^KEY_VAULT_/;
27
+
28
+ /** URL with embedded credentials: scheme://user:password@host → scheme://user:***@host */
29
+ const URL_CREDENTIAL_PATTERN = /(\w+:\/\/)([^:@]*):([^@]+)@/g;
30
+
31
+ /**
32
+ * Masks a single env line if the key looks like a secret or value contains URL credentials
33
+ * @param {string} line - Line in form KEY=value
34
+ * @returns {string} Same line or KEY=*** or value with masked URL credentials
35
+ */
36
+ function maskEnvLine(line) {
37
+ const eq = line.indexOf('=');
38
+ if (eq <= 0) return line;
39
+ const key = line.slice(0, eq);
40
+ const value = line.slice(eq + 1);
41
+
42
+ const keyForCheck = key.replace(KEY_PREFIXES_TO_STRIP, '');
43
+ const isSecretKey = SECRET_KEY_PATTERN.test(keyForCheck);
44
+
45
+ const maskedValue = value.replace(URL_CREDENTIAL_PATTERN, '$1$2:***@');
46
+ const hasUrlCredentials = maskedValue !== value;
47
+
48
+ if (isSecretKey) return `${key}=***`;
49
+ if (hasUrlCredentials) return `${key}=${maskedValue}`;
50
+ return line;
51
+ }
52
+
53
+ /**
54
+ * Dump container env (masked) and print to logger
55
+ * @async
56
+ * @param {string} containerName - Docker container name
57
+ * @returns {Promise<void>}
58
+ */
59
+ async function dumpMaskedEnv(containerName) {
60
+ try {
61
+ const { stdout } = await execAsync(`docker exec ${containerName} env`, { encoding: 'utf8', timeout: 5000 });
62
+ const lines = stdout.split('\n').filter((l) => l.trim());
63
+ if (lines.length === 0) return;
64
+ logger.log(chalk.bold('\n--- Environment (sensitive values masked) ---\n'));
65
+ lines.sort((a, b) => {
66
+ const keyA = a.indexOf('=') > 0 ? a.slice(0, a.indexOf('=')) : a;
67
+ const keyB = b.indexOf('=') > 0 ? b.slice(0, b.indexOf('=')) : b;
68
+ return keyA.localeCompare(keyB);
69
+ });
70
+ lines.forEach((line) => logger.log(maskEnvLine(line)));
71
+ logger.log(chalk.gray('\n--- Logs ---\n'));
72
+ } catch (err) {
73
+ logger.log(chalk.gray('(Could not read container env; container may be stopped)\n'));
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Run docker logs (non-follow): tail N lines or full (tail 0)
79
+ * @async
80
+ * @param {string} containerName - Docker container name
81
+ * @param {Object} options - { tail: number } (0 = full, no limit)
82
+ * @returns {Promise<void>}
83
+ */
84
+ async function runDockerLogs(containerName, options) {
85
+ const args = options.tail === 0 ? ['logs', containerName] : ['logs', '--tail', String(options.tail), containerName];
86
+ return new Promise((resolve, reject) => {
87
+ const proc = spawn('docker', args, { stdio: 'inherit' });
88
+ proc.on('error', reject);
89
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`docker logs exited with ${code}`))));
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Run docker logs --follow (stream), optionally with tail
95
+ * @param {string} containerName - Docker container name
96
+ * @param {number} [tail] - Lines to show (0 = full, omit --tail)
97
+ */
98
+ function runDockerLogsFollow(containerName, tail) {
99
+ const args = tail === 0 ? ['logs', '-f', containerName] : ['logs', '-f', '--tail', String(tail), containerName];
100
+ const proc = spawn('docker', args, { stdio: 'inherit' });
101
+ proc.on('error', (err) => {
102
+ logger.log(chalk.red(`Error: ${err.message}`));
103
+ process.exit(1);
104
+ });
105
+ proc.on('close', (code) => {
106
+ if (code !== 0 && code !== null) process.exit(code);
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Run app logs command: optional env dump (masked), then docker logs
112
+ * @async
113
+ * @param {string} appKey - Application key (app name)
114
+ * @param {Object} options - CLI options
115
+ * @param {boolean} [options.follow] - Follow log stream (-f)
116
+ * @param {number} [options.tail] - Number of lines (default 100; 0 = full list)
117
+ * @returns {Promise<void>}
118
+ */
119
+ async function runAppLogs(appKey, options = {}) {
120
+ validateAppName(appKey);
121
+ const developerId = await config.getDeveloperId();
122
+ const containerName = containerHelpers.getContainerName(appKey, developerId);
123
+
124
+ const follow = !!options.follow;
125
+ const tail = typeof options.tail === 'number' ? options.tail : DEFAULT_TAIL_LINES;
126
+
127
+ logger.log(chalk.blue(`Container: ${containerName}\n`));
128
+
129
+ if (!follow) {
130
+ await dumpMaskedEnv(containerName);
131
+ }
132
+
133
+ if (follow) {
134
+ runDockerLogsFollow(containerName, tail);
135
+ return;
136
+ }
137
+
138
+ try {
139
+ await runDockerLogs(containerName, { tail });
140
+ } catch (err) {
141
+ logger.log(chalk.red(`Error: ${err.message}`));
142
+ throw new Error(`Failed to show logs: ${err.message}`);
143
+ }
144
+ }
145
+
146
+ module.exports = { runAppLogs, maskEnvLine };
@@ -15,6 +15,7 @@ const { listApplications } = require('../app/list');
15
15
  const { registerApplication } = require('../app/register');
16
16
  const { rotateSecret } = require('../app/rotate-secret');
17
17
  const { showApp } = require('../app/show');
18
+ const { runAppDeploymentList } = require('./deployment-list');
18
19
 
19
20
  /**
20
21
  * Setup application management commands
@@ -73,9 +74,31 @@ function setupAppCommands(program) {
73
74
  .command('show <appKey>')
74
75
  .description('Show application from controller (online). Same as aifabrix show <appKey> --online')
75
76
  .option('--json', 'Output as JSON')
77
+ .option('--permissions', 'Show only list of permissions')
76
78
  .action(async(appKey, options) => {
77
79
  try {
78
- await showApp(appKey, { online: true, json: !!options.json });
80
+ await showApp(appKey, { online: true, json: !!options.json, permissions: !!options.permissions });
81
+ } catch (error) {
82
+ logger.error(chalk.red(`Error: ${error.message}`));
83
+ process.exit(1);
84
+ }
85
+ });
86
+
87
+ // Deployment list for an application
88
+ app
89
+ .command('deployment <appKey>')
90
+ .description('List last N deployments for an application in current environment (default pageSize=50)')
91
+ .option('--controller <url>', 'Controller URL (default: from config)')
92
+ .option('--environment <env>', 'Environment key (default: from config)')
93
+ .option('--page-size <n>', 'Items per page', '50')
94
+ .action(async(appKey, options) => {
95
+ try {
96
+ const opts = {
97
+ controller: options.controller,
98
+ environment: options.environment,
99
+ pageSize: parseInt(options.pageSize, 10) || 50
100
+ };
101
+ await runAppDeploymentList(appKey, opts);
79
102
  } catch (error) {
80
103
  logger.error(chalk.red(`Error: ${error.message}`));
81
104
  process.exit(1);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Credential list command – list credentials from controller/dataplane
3
+ * GET /api/v1/credential. Used by `aifabrix credential list`.
4
+ *
5
+ * @fileoverview Credential list command implementation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { resolveControllerUrl } = require('../utils/controller-url');
13
+ const { getOrRefreshDeviceToken } = require('../utils/token-manager');
14
+ const { normalizeControllerUrl } = require('../core/config');
15
+ const { listCredentials } = require('../api/credentials.api');
16
+
17
+ const DEFAULT_PAGE_SIZE = 50;
18
+
19
+ /**
20
+ * Get auth token for credential list (device token from config)
21
+ * @async
22
+ * @param {string} controllerUrl - Controller base URL
23
+ * @returns {Promise<{token: string, controllerUrl: string}|null>}
24
+ */
25
+ async function getCredentialListAuth(controllerUrl) {
26
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
27
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
28
+ if (deviceToken && deviceToken.token) {
29
+ return {
30
+ token: deviceToken.token,
31
+ controllerUrl: deviceToken.controller || normalizedUrl
32
+ };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Extract credentials array from API response
39
+ * @param {Object} response - API response
40
+ * @returns {Array}
41
+ */
42
+ function extractCredentials(response) {
43
+ const data = response?.data ?? response;
44
+ const items = data?.credentials ?? data?.items ?? (Array.isArray(data) ? data : []);
45
+ return Array.isArray(items) ? items : [];
46
+ }
47
+
48
+ /**
49
+ * Display credential list to user
50
+ * @param {Array} list - Credentials array
51
+ * @param {string} controllerUrl - Controller URL for header
52
+ */
53
+ function displayCredentialList(list, controllerUrl) {
54
+ logger.log(chalk.bold(`\n🔐 Credentials (${controllerUrl}):\n`));
55
+ if (list.length === 0) {
56
+ logger.log(chalk.gray(' No credentials found.\n'));
57
+ return;
58
+ }
59
+ list.forEach((c) => {
60
+ const key = c.key ?? c.id ?? c.credentialKey ?? '-';
61
+ const name = c.displayName ?? c.name ?? key;
62
+ logger.log(` ${chalk.cyan(key)} - ${name}`);
63
+ });
64
+ logger.log('');
65
+ }
66
+
67
+ /**
68
+ * Run credential list command: call GET /api/v1/credential and display results
69
+ * @async
70
+ * @param {Object} options - CLI options
71
+ * @param {string} [options.controller] - Controller URL override
72
+ * @param {boolean} [options.activeOnly] - List only active credentials
73
+ * @param {number} [options.pageSize] - Items per page
74
+ * @returns {Promise<void>}
75
+ */
76
+ async function runCredentialList(options = {}) {
77
+ const controllerUrl = options.controller || (await resolveControllerUrl());
78
+ if (!controllerUrl) {
79
+ logger.error(chalk.red('❌ Controller URL is required. Run "aifabrix login" first.'));
80
+ process.exit(1);
81
+ return;
82
+ }
83
+ const authResult = await getCredentialListAuth(controllerUrl);
84
+ if (!authResult || !authResult.token) {
85
+ logger.error(chalk.red(`❌ No authentication token for controller: ${controllerUrl}`));
86
+ logger.error(chalk.gray('Run: aifabrix login'));
87
+ process.exit(1);
88
+ return;
89
+ }
90
+ const authConfig = { type: 'bearer', token: authResult.token };
91
+ const listOptions = {
92
+ pageSize: options.pageSize || DEFAULT_PAGE_SIZE,
93
+ activeOnly: options.activeOnly
94
+ };
95
+ try {
96
+ const response = await listCredentials(authResult.controllerUrl, authConfig, listOptions);
97
+ displayCredentialList(extractCredentials(response), authResult.controllerUrl);
98
+ } catch (error) {
99
+ logger.error(chalk.red(`❌ Failed to list credentials: ${error.message}`));
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ module.exports = { runCredentialList };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Deployment list commands – list deployments for environment or for an app
3
+ * Uses GET .../deployments and GET .../applications/{appKey}/deployments.
4
+ *
5
+ * @fileoverview Deployment list command implementation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { resolveControllerUrl } = require('../utils/controller-url');
13
+ const { resolveEnvironment } = require('../core/config');
14
+ const { getOrRefreshDeviceToken } = require('../utils/token-manager');
15
+ const { listDeployments, listApplicationDeployments } = require('../api/deployments.api');
16
+ const { normalizeControllerUrl } = require('../core/config');
17
+
18
+ const DEFAULT_PAGE_SIZE = 50;
19
+
20
+ /**
21
+ * Get auth token for deployment list (device token from config)
22
+ * @async
23
+ * @param {string} controllerUrl - Controller base URL
24
+ * @returns {Promise<{token: string, controllerUrl: string}|null>}
25
+ */
26
+ async function getDeploymentListAuth(controllerUrl) {
27
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
28
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
29
+ if (deviceToken && deviceToken.token) {
30
+ return {
31
+ token: deviceToken.token,
32
+ controllerUrl: deviceToken.controller || normalizedUrl
33
+ };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Extract deployments array from API response
40
+ * @param {Object} response - API response
41
+ * @returns {Array}
42
+ */
43
+ function extractDeployments(response) {
44
+ const data = response?.data ?? response;
45
+ const items = data?.items ?? data?.deployments ?? (Array.isArray(data) ? data : []);
46
+ return Array.isArray(items) ? items : [];
47
+ }
48
+
49
+ /**
50
+ * Display environment deployment list to user
51
+ * @param {Array} deployments - Deployments array
52
+ * @param {string} environment - Environment key
53
+ * @param {string} controllerUrl - Controller URL
54
+ */
55
+ function displayDeploymentList(deployments, environment, controllerUrl) {
56
+ logger.log(chalk.bold(`\n📋 Deployments (${environment}) at ${controllerUrl}:\n`));
57
+ if (deployments.length === 0) {
58
+ logger.log(chalk.gray(' No deployments found.\n'));
59
+ return;
60
+ }
61
+ deployments.forEach((d) => {
62
+ const id = d.id ?? d.deploymentId ?? '-';
63
+ const appKey = d.applicationKey ?? d.appKey ?? d.application?.key ?? '-';
64
+ const status = d.status ?? '-';
65
+ const createdAt = d.createdAt ?? d.created ?? '';
66
+ logger.log(` ${chalk.cyan(id)} ${appKey} ${status} ${chalk.gray(createdAt)}`);
67
+ });
68
+ logger.log('');
69
+ }
70
+
71
+ /**
72
+ * Run deployment list (environment): list last N deployments for current environment
73
+ * @async
74
+ * @param {Object} options - CLI options
75
+ * @param {string} [options.controller] - Controller URL override
76
+ * @param {string} [options.environment] - Environment key override
77
+ * @param {number} [options.pageSize] - Items per page (default 50)
78
+ * @returns {Promise<void>}
79
+ */
80
+ async function runDeploymentList(options = {}) {
81
+ const { environment, authResult } = await resolveDeploymentListContext(options);
82
+ const authConfig = { type: 'bearer', token: authResult.token };
83
+ const listOptions = { pageSize: options.pageSize || DEFAULT_PAGE_SIZE };
84
+ try {
85
+ const response = await listDeployments(
86
+ authResult.controllerUrl,
87
+ environment,
88
+ authConfig,
89
+ listOptions
90
+ );
91
+ displayDeploymentList(extractDeployments(response), environment, authResult.controllerUrl);
92
+ } catch (error) {
93
+ logger.error(chalk.red(`❌ Failed to list deployments: ${error.message}`));
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Display app deployment list to user
100
+ * @param {Array} deployments - Deployments array
101
+ * @param {string} appKey - Application key
102
+ * @param {string} environment - Environment key
103
+ * @param {string} controllerUrl - Controller URL
104
+ */
105
+ function displayAppDeploymentList(deployments, appKey, environment, controllerUrl) {
106
+ logger.log(chalk.bold(`\n📋 Deployments for ${appKey} (${environment}) at ${controllerUrl}:\n`));
107
+ if (deployments.length === 0) {
108
+ logger.log(chalk.gray(' No deployments found for this application.\n'));
109
+ return;
110
+ }
111
+ deployments.forEach((d) => {
112
+ const id = d.id ?? d.deploymentId ?? '-';
113
+ const status = d.status ?? '-';
114
+ const createdAt = d.createdAt ?? d.created ?? '';
115
+ logger.log(` ${chalk.cyan(id)} ${status} ${chalk.gray(createdAt)}`);
116
+ });
117
+ logger.log('');
118
+ }
119
+
120
+ /**
121
+ * Resolve controller URL, environment, and auth for deployment list commands
122
+ * @async
123
+ * @param {Object} options - Options with optional controller, environment
124
+ * @returns {Promise<{controllerUrl: string, environment: string, authResult: Object}>}
125
+ */
126
+ async function resolveDeploymentListContext(options) {
127
+ const controllerUrl = options.controller || (await resolveControllerUrl());
128
+ if (!controllerUrl) {
129
+ logger.error(chalk.red('❌ Controller URL is required. Run "aifabrix login" first.'));
130
+ process.exit(1);
131
+ }
132
+ const environment = options.environment || (await resolveEnvironment());
133
+ const authResult = await getDeploymentListAuth(controllerUrl);
134
+ if (!authResult || !authResult.token) {
135
+ logger.error(chalk.red(`❌ No authentication token for controller: ${controllerUrl}`));
136
+ logger.error(chalk.gray('Run: aifabrix login'));
137
+ process.exit(1);
138
+ }
139
+ return { controllerUrl, environment, authResult };
140
+ }
141
+
142
+ /**
143
+ * Run app deployment list: list last N deployments for an application
144
+ * @async
145
+ * @param {string} appKey - Application key
146
+ * @param {Object} options - CLI options
147
+ * @param {string} [options.controller] - Controller URL override
148
+ * @param {string} [options.environment] - Environment key override
149
+ * @param {number} [options.pageSize] - Items per page (default 50)
150
+ * @returns {Promise<void>}
151
+ */
152
+ async function runAppDeploymentList(appKey, options = {}) {
153
+ if (!appKey || typeof appKey !== 'string') {
154
+ logger.error(chalk.red('❌ Application key is required.'));
155
+ process.exit(1);
156
+ return;
157
+ }
158
+ const { environment, authResult } = await resolveDeploymentListContext(options);
159
+ const authConfig = { type: 'bearer', token: authResult.token };
160
+ const listOptions = { pageSize: options.pageSize || DEFAULT_PAGE_SIZE };
161
+ try {
162
+ const response = await listApplicationDeployments(
163
+ authResult.controllerUrl,
164
+ environment,
165
+ appKey,
166
+ authConfig,
167
+ listOptions
168
+ );
169
+ displayAppDeploymentList(
170
+ extractDeployments(response),
171
+ appKey,
172
+ environment,
173
+ authResult.controllerUrl
174
+ );
175
+ } catch (error) {
176
+ logger.error(chalk.red(`❌ Failed to list deployments for ${appKey}: ${error.message}`));
177
+ process.exit(1);
178
+ }
179
+ }
180
+
181
+ module.exports = {
182
+ runDeploymentList,
183
+ runAppDeploymentList
184
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Service user create command – create service user and get one-time secret
3
+ * POST /api/v1/service-users. Used by `aifabrix service-user create`.
4
+ *
5
+ * @fileoverview Service user create command implementation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { resolveControllerUrl } = require('../utils/controller-url');
13
+ const { getOrRefreshDeviceToken } = require('../utils/token-manager');
14
+ const { normalizeControllerUrl } = require('../core/config');
15
+ const { createServiceUser } = require('../api/service-users.api');
16
+
17
+ const ONE_TIME_WARNING =
18
+ 'Save this secret now; it will not be shown again.';
19
+
20
+ /**
21
+ * Get auth token for service-user (device token from config)
22
+ * @async
23
+ * @param {string} controllerUrl - Controller base URL
24
+ * @returns {Promise<{token: string, controllerUrl: string}|null>}
25
+ */
26
+ async function getServiceUserAuth(controllerUrl) {
27
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
28
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
29
+ if (deviceToken && deviceToken.token) {
30
+ return {
31
+ token: deviceToken.token,
32
+ controllerUrl: deviceToken.controller || normalizedUrl
33
+ };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Extract clientId and clientSecret from API response (response may be wrapped in data)
40
+ * @param {Object} response - API response
41
+ * @returns {{ clientId: string, clientSecret: string }}
42
+ */
43
+ function extractCreateResponse(response) {
44
+ const data = response?.data ?? response;
45
+ return {
46
+ clientId: data?.clientId ?? '',
47
+ clientSecret: data?.clientSecret ?? ''
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Log error for failed create response and exit
53
+ * @param {Object} response - API response with success: false
54
+ */
55
+ function handleCreateError(response) {
56
+ const status = response.status;
57
+ const msg = response.formattedError || response.error || 'Request failed';
58
+ if (status === 400) {
59
+ logger.error(chalk.red(`❌ Validation error: ${msg}`));
60
+ } else if (status === 401) {
61
+ logger.error(chalk.red('❌ Unauthorized. Run "aifabrix login" and try again.'));
62
+ } else if (status === 403) {
63
+ logger.error(chalk.red('❌ Missing permission: service-user:create'));
64
+ logger.error(chalk.gray('Your account needs the service-user:create permission on the controller.'));
65
+ } else {
66
+ logger.error(chalk.red(`❌ Failed to create service user: ${msg}`));
67
+ }
68
+ process.exit(1);
69
+ }
70
+
71
+ /**
72
+ * Display success output with clientId, clientSecret and one-time warning
73
+ * @param {string} clientId - Service user client ID
74
+ * @param {string} clientSecret - One-time client secret
75
+ */
76
+ function displayCreateSuccess(clientId, clientSecret) {
77
+ logger.log(chalk.bold('\n✓ Service user created\n'));
78
+ logger.log(chalk.cyan(' clientId: ') + clientId);
79
+ logger.log(chalk.cyan(' clientSecret: ') + clientSecret);
80
+ logger.log('');
81
+ logger.log(chalk.yellow('⚠ ' + ONE_TIME_WARNING));
82
+ logger.log('');
83
+ }
84
+
85
+ /**
86
+ * Parse comma-separated string into non-empty trimmed array
87
+ * @param {string} [val] - Comma-separated value
88
+ * @returns {string[]}
89
+ */
90
+ function parseList(val) {
91
+ if (val === undefined || val === null || String(val).trim() === '') {
92
+ return [];
93
+ }
94
+ return String(val)
95
+ .split(',')
96
+ .map(s => s.trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ /**
101
+ * Validate username, email, redirectUris, groupIds; exit on failure
102
+ * @param {Object} options - CLI options
103
+ * @returns {{ username: string, email: string, redirectUris: string[], groupNames: string[], description?: string }}
104
+ */
105
+ function validateServiceUserOptions(options) {
106
+ const username = options.username?.trim();
107
+ const email = options.email?.trim();
108
+ const redirectUris = parseList(options.redirectUris);
109
+ const groupNames = parseList(options.groupNames);
110
+ if (!username) {
111
+ logger.error(chalk.red('❌ Username is required. Use --username <username>.'));
112
+ process.exit(1);
113
+ }
114
+ if (!email) {
115
+ logger.error(chalk.red('❌ Email is required. Use --email <email>.'));
116
+ process.exit(1);
117
+ }
118
+ if (redirectUris.length === 0) {
119
+ logger.error(chalk.red('❌ At least one redirect URI is required. Use --redirect-uris <uri1,uri2,...>.'));
120
+ process.exit(1);
121
+ }
122
+ if (groupNames.length === 0) {
123
+ logger.error(chalk.red('❌ At least one group name is required. Use --group-names <name1,name2,...>.'));
124
+ process.exit(1);
125
+ }
126
+ return {
127
+ username,
128
+ email,
129
+ redirectUris,
130
+ groupNames,
131
+ description: options.description?.trim() || undefined
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Resolve controller URL and auth; exit on failure
137
+ * @async
138
+ * @param {Object} options - CLI options
139
+ * @returns {Promise<{ username: string, email: string, redirectUris: string[], groupNames: string[], description?: string, controllerUrl: string, authConfig: Object }>}
140
+ */
141
+ async function resolveOptionsAndAuth(options) {
142
+ const validated = validateServiceUserOptions(options);
143
+ const controllerUrl = options.controller || (await resolveControllerUrl());
144
+ if (!controllerUrl) {
145
+ logger.error(chalk.red('❌ Controller URL is required. Run "aifabrix login" first.'));
146
+ process.exit(1);
147
+ }
148
+ const authResult = await getServiceUserAuth(controllerUrl);
149
+ if (!authResult || !authResult.token) {
150
+ logger.error(chalk.red(`❌ No authentication token for controller: ${controllerUrl}`));
151
+ logger.error(chalk.gray('Run: aifabrix login'));
152
+ process.exit(1);
153
+ }
154
+ return {
155
+ ...validated,
156
+ controllerUrl: authResult.controllerUrl,
157
+ authConfig: { type: 'bearer', token: authResult.token }
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Run service-user create: call POST /api/v1/service-users and display one-time secret with warning
163
+ * @async
164
+ * @param {Object} options - CLI options
165
+ * @param {string} [options.controller] - Controller URL override
166
+ * @param {string} options.username - Username (required)
167
+ * @param {string} options.email - Email (required)
168
+ * @param {string} options.redirectUris - Comma-separated redirect URIs (required, min 1)
169
+ * @param {string} options.groupNames - Comma-separated group names (required, e.g. AI-Fabrix-Developers)
170
+ * @param {string} [options.description] - Optional description
171
+ * @returns {Promise<void>}
172
+ */
173
+ async function runServiceUserCreate(options = {}) {
174
+ const ctx = await resolveOptionsAndAuth(options);
175
+ const body = {
176
+ username: ctx.username,
177
+ email: ctx.email,
178
+ redirectUris: ctx.redirectUris,
179
+ groupNames: ctx.groupNames,
180
+ description: ctx.description
181
+ };
182
+ const response = await createServiceUser(ctx.controllerUrl, ctx.authConfig, body);
183
+ if (!response.success) {
184
+ handleCreateError(response);
185
+ return;
186
+ }
187
+ const { clientId, clientSecret } = extractCreateResponse(response);
188
+ displayCreateSuccess(clientId, clientSecret);
189
+ }
190
+
191
+ module.exports = {
192
+ runServiceUserCreate
193
+ };