@aifabrix/builder 2.1.7 → 2.3.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 (43) hide show
  1. package/lib/app-deploy.js +73 -29
  2. package/lib/app-list.js +132 -0
  3. package/lib/app-readme.js +11 -4
  4. package/lib/app-register.js +435 -0
  5. package/lib/app-rotate-secret.js +164 -0
  6. package/lib/app-run.js +98 -84
  7. package/lib/app.js +13 -0
  8. package/lib/audit-logger.js +195 -15
  9. package/lib/build.js +155 -42
  10. package/lib/cli.js +104 -8
  11. package/lib/commands/app.js +8 -391
  12. package/lib/commands/login.js +130 -36
  13. package/lib/commands/secure.js +260 -0
  14. package/lib/config.js +315 -4
  15. package/lib/deployer.js +221 -183
  16. package/lib/infra.js +177 -112
  17. package/lib/push.js +34 -7
  18. package/lib/secrets.js +89 -23
  19. package/lib/templates.js +1 -1
  20. package/lib/utils/api-error-handler.js +465 -0
  21. package/lib/utils/api.js +165 -16
  22. package/lib/utils/auth-headers.js +84 -0
  23. package/lib/utils/build-copy.js +162 -0
  24. package/lib/utils/cli-utils.js +49 -3
  25. package/lib/utils/compose-generator.js +57 -16
  26. package/lib/utils/deployment-errors.js +90 -0
  27. package/lib/utils/deployment-validation.js +60 -0
  28. package/lib/utils/dev-config.js +83 -0
  29. package/lib/utils/docker-build.js +24 -0
  30. package/lib/utils/env-template.js +30 -10
  31. package/lib/utils/health-check.js +18 -1
  32. package/lib/utils/infra-containers.js +101 -0
  33. package/lib/utils/local-secrets.js +0 -2
  34. package/lib/utils/secrets-encryption.js +203 -0
  35. package/lib/utils/secrets-path.js +22 -3
  36. package/lib/utils/token-manager.js +381 -0
  37. package/package.json +2 -2
  38. package/templates/applications/README.md.hbs +155 -23
  39. package/templates/applications/miso-controller/Dockerfile +7 -119
  40. package/templates/infra/compose.yaml.hbs +93 -0
  41. package/templates/python/docker-compose.hbs +25 -17
  42. package/templates/typescript/docker-compose.hbs +25 -17
  43. package/test-output.txt +0 -5431
@@ -0,0 +1,435 @@
1
+ /**
2
+ * AI Fabrix Builder - App Register Command
3
+ *
4
+ * Handles application registration and credential generation
5
+ *
6
+ * @fileoverview App register command implementation for AI Fabrix Builder
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const fs = require('fs').promises;
12
+ const path = require('path');
13
+ const chalk = require('chalk');
14
+ const yaml = require('js-yaml');
15
+ const { getConfig } = require('./config');
16
+ const { authenticatedApiCall } = require('./utils/api');
17
+ const { formatApiError } = require('./utils/api-error-handler');
18
+ const logger = require('./utils/logger');
19
+ const { saveLocalSecret, isLocalhost } = require('./utils/local-secrets');
20
+ const { updateEnvTemplate } = require('./utils/env-template');
21
+ const { getOrRefreshDeviceToken } = require('./utils/token-manager');
22
+
23
+ // Import createApp to auto-generate config if missing
24
+ let createApp;
25
+ try {
26
+ createApp = require('./app').createApp;
27
+ } catch {
28
+ createApp = null;
29
+ }
30
+
31
+ /**
32
+ * Validation schema for application registration
33
+ */
34
+ const registerApplicationSchema = {
35
+ environmentId: (val) => {
36
+ if (!val || val.length < 1) {
37
+ throw new Error('Invalid environment ID format');
38
+ }
39
+ return val;
40
+ },
41
+ key: (val) => {
42
+ if (!val || val.length < 1) {
43
+ throw new Error('Application key is required');
44
+ }
45
+ if (val.length > 50) {
46
+ throw new Error('Application key must be at most 50 characters');
47
+ }
48
+ if (!/^[a-z0-9-]+$/.test(val)) {
49
+ throw new Error('Application key must contain only lowercase letters, numbers, and hyphens');
50
+ }
51
+ return val;
52
+ },
53
+ displayName: (val) => {
54
+ if (!val || val.length < 1) {
55
+ throw new Error('Display name is required');
56
+ }
57
+ if (val.length > 100) {
58
+ throw new Error('Display name must be at most 100 characters');
59
+ }
60
+ return val;
61
+ },
62
+ description: (val) => val || undefined,
63
+ configuration: (val) => {
64
+ const validTypes = ['webapp', 'api', 'service', 'functionapp'];
65
+ const validRegistryModes = ['acr', 'external', 'public'];
66
+
67
+ if (!val || !val.type || !validTypes.includes(val.type)) {
68
+ throw new Error('Configuration type must be one of: webapp, api, service, functionapp');
69
+ }
70
+ if (!val.registryMode || !validRegistryModes.includes(val.registryMode)) {
71
+ throw new Error('Registry mode must be one of: acr, external, public');
72
+ }
73
+ if (val.port !== undefined) {
74
+ if (!Number.isInteger(val.port) || val.port < 1 || val.port > 65535) {
75
+ throw new Error('Port must be an integer between 1 and 65535');
76
+ }
77
+ }
78
+ return val;
79
+ }
80
+ };
81
+
82
+ /**
83
+ * Load variables.yaml file for an application
84
+ * @async
85
+ * @param {string} appKey - Application key
86
+ * @returns {Promise<{variables: Object, created: boolean}>} Variables and creation flag
87
+ */
88
+ async function loadVariablesYaml(appKey) {
89
+ const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
90
+
91
+ try {
92
+ const variablesContent = await fs.readFile(variablesPath, 'utf-8');
93
+ return { variables: yaml.load(variablesContent), created: false };
94
+ } catch (error) {
95
+ if (error.code === 'ENOENT') {
96
+ logger.log(chalk.yellow(`āš ļø variables.yaml not found for ${appKey}`));
97
+ logger.log(chalk.yellow('šŸ“ Creating minimal configuration...\n'));
98
+ return { variables: null, created: true };
99
+ }
100
+ throw new Error(`Failed to read variables.yaml: ${error.message}`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Create minimal application configuration if needed
106
+ * @async
107
+ * @param {string} appKey - Application key
108
+ * @param {Object} options - Registration options
109
+ * @returns {Promise<Object>} Variables after creation
110
+ */
111
+ async function createMinimalAppIfNeeded(appKey, options) {
112
+ if (!createApp) {
113
+ throw new Error('Cannot auto-create application: createApp function not available');
114
+ }
115
+
116
+ await createApp(appKey, {
117
+ port: options.port,
118
+ language: 'typescript',
119
+ database: false,
120
+ redis: false,
121
+ storage: false,
122
+ authentication: false
123
+ });
124
+
125
+ const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
126
+ const variablesContent = await fs.readFile(variablesPath, 'utf-8');
127
+ return yaml.load(variablesContent);
128
+ }
129
+
130
+ /**
131
+ * Extract application configuration from variables.yaml
132
+ * @param {Object} variables - Variables from YAML file
133
+ * @param {string} appKey - Application key
134
+ * @param {Object} options - Registration options
135
+ * @returns {Object} Extracted configuration
136
+ */
137
+ function extractAppConfiguration(variables, appKey, options) {
138
+ const appKeyFromFile = variables.app?.key || appKey;
139
+ const displayName = variables.app?.name || options.name || appKey;
140
+ const description = variables.app?.description || '';
141
+ const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
142
+ const registryMode = 'external';
143
+ const port = variables.build?.port || options.port || 3000;
144
+ const language = variables.build?.language || 'typescript';
145
+
146
+ return {
147
+ appKey: appKeyFromFile,
148
+ displayName,
149
+ description,
150
+ appType,
151
+ registryMode,
152
+ port,
153
+ language
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Validate application registration data
159
+ * @param {Object} config - Application configuration
160
+ * @param {string} originalAppKey - Original app key for error messages
161
+ * @throws {Error} If validation fails
162
+ */
163
+ function validateAppRegistrationData(config, originalAppKey) {
164
+ const missingFields = [];
165
+ if (!config.appKey) missingFields.push('app.key');
166
+ if (!config.displayName) missingFields.push('app.name');
167
+
168
+ if (missingFields.length > 0) {
169
+ logger.error(chalk.red('āŒ Missing required fields in variables.yaml:'));
170
+ missingFields.forEach(field => logger.error(chalk.red(` - ${field}`)));
171
+ logger.error(chalk.red(`\n Please update builder/${originalAppKey}/variables.yaml and try again.`));
172
+ process.exit(1);
173
+ }
174
+
175
+ try {
176
+ registerApplicationSchema.key(config.appKey);
177
+ registerApplicationSchema.displayName(config.displayName);
178
+ registerApplicationSchema.configuration({
179
+ type: config.appType,
180
+ registryMode: config.registryMode,
181
+ port: config.port
182
+ });
183
+ } catch (error) {
184
+ logger.error(chalk.red(`āŒ Invalid configuration: ${error.message}`));
185
+ process.exit(1);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if user is authenticated and get token
191
+ * @async
192
+ * @param {string} [controllerUrl] - Optional controller URL from variables.yaml
193
+ * @param {string} [environment] - Optional environment key
194
+ * @returns {Promise<{apiUrl: string, token: string}>} Configuration with API URL and token
195
+ */
196
+ async function checkAuthentication(controllerUrl, environment) {
197
+ const config = await getConfig();
198
+
199
+ // Try to get controller URL from parameter, config, or device tokens
200
+ let finalControllerUrl = controllerUrl;
201
+ let token = null;
202
+
203
+ // If controller URL provided, try to get device token
204
+ if (finalControllerUrl) {
205
+ const deviceToken = await getOrRefreshDeviceToken(finalControllerUrl);
206
+ if (deviceToken && deviceToken.token) {
207
+ token = deviceToken.token;
208
+ finalControllerUrl = deviceToken.controller;
209
+ }
210
+ }
211
+
212
+ // If no token yet, try to find any device token in config
213
+ if (!token && config.device) {
214
+ const deviceUrls = Object.keys(config.device);
215
+ if (deviceUrls.length > 0) {
216
+ // Use first available device token
217
+ finalControllerUrl = deviceUrls[0];
218
+ const deviceToken = await getOrRefreshDeviceToken(finalControllerUrl);
219
+ if (deviceToken && deviceToken.token) {
220
+ token = deviceToken.token;
221
+ finalControllerUrl = deviceToken.controller;
222
+ }
223
+ }
224
+ }
225
+
226
+ // If still no token, check for client token (requires environment and app)
227
+ if (!token && environment) {
228
+ // For app register, we don't have an app yet, so client tokens won't work
229
+ // This is expected - device tokens should be used for registration
230
+ }
231
+
232
+ if (!token || !finalControllerUrl) {
233
+ logger.error(chalk.red('āŒ Not logged in. Run: aifabrix login'));
234
+ logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
235
+ process.exit(1);
236
+ }
237
+
238
+ return {
239
+ apiUrl: finalControllerUrl,
240
+ token: token
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Call registration API
246
+ * @async
247
+ * @param {string} apiUrl - API URL
248
+ * @param {string} token - Authentication token
249
+ * @param {string} environment - Environment ID
250
+ * @param {Object} registrationData - Registration data
251
+ * @returns {Promise<Object>} API response
252
+ */
253
+ async function callRegisterApi(apiUrl, token, environment, registrationData) {
254
+ const response = await authenticatedApiCall(
255
+ `${apiUrl}/api/v1/environments/${encodeURIComponent(environment)}/applications/register`,
256
+ {
257
+ method: 'POST',
258
+ body: JSON.stringify(registrationData)
259
+ },
260
+ token
261
+ );
262
+
263
+ if (!response.success) {
264
+ const formattedError = response.formattedError || formatApiError(response);
265
+ logger.error(formattedError);
266
+ process.exit(1);
267
+ }
268
+
269
+ // Handle API response structure:
270
+ // makeApiCall returns: { success: true, data: <API response> }
271
+ // API response can be:
272
+ // 1. Direct format: { application: {...}, credentials: {...} }
273
+ // 2. Wrapped format: { success: true, data: { application: {...}, credentials: {...} } }
274
+ const apiResponse = response.data;
275
+ if (apiResponse && apiResponse.data && apiResponse.data.application) {
276
+ // Wrapped format: use apiResponse.data
277
+ return apiResponse.data;
278
+ } else if (apiResponse && apiResponse.application) {
279
+ // Direct format: use apiResponse directly
280
+ return apiResponse;
281
+ }
282
+ // Fallback: return apiResponse as-is (shouldn't happen, but handle gracefully)
283
+ logger.error(chalk.red('āŒ Invalid response: missing application data'));
284
+ logger.error(chalk.gray('\nFull response for debugging:'));
285
+ logger.error(chalk.gray(JSON.stringify(response, null, 2)));
286
+ process.exit(1);
287
+
288
+ }
289
+
290
+ /**
291
+ * Get environment prefix for GitHub Secrets
292
+ * @param {string} environment - Environment key (e.g., 'dev', 'tst', 'pro', 'miso')
293
+ * @returns {string} Uppercase prefix (e.g., 'DEV', 'TST', 'PRO', 'MISO')
294
+ */
295
+ function getEnvironmentPrefix(environment) {
296
+ if (!environment) {
297
+ return 'DEV';
298
+ }
299
+ // Convert to uppercase and handle common variations
300
+ const env = environment.toLowerCase();
301
+ if (env === 'dev' || env === 'development') {
302
+ return 'DEV';
303
+ }
304
+ if (env === 'tst' || env === 'test' || env === 'staging') {
305
+ return 'TST';
306
+ }
307
+ if (env === 'pro' || env === 'prod' || env === 'production') {
308
+ return 'PRO';
309
+ }
310
+ // For other environments (e.g., 'miso'), uppercase the entire string
311
+ // Use full string if 4 characters or less, otherwise use first 4 characters
312
+ const upper = environment.toUpperCase();
313
+ return upper.length <= 4 ? upper : upper.substring(0, 4);
314
+ }
315
+
316
+ /**
317
+ * Display registration success and credentials
318
+ * @param {Object} data - Registration response data
319
+ * @param {string} apiUrl - API URL
320
+ * @param {string} environment - Environment key
321
+ */
322
+ function displayRegistrationResults(data, apiUrl, environment) {
323
+ logger.log(chalk.green('āœ… Application registered successfully!\n'));
324
+ logger.log(chalk.bold('šŸ“‹ Application Details:'));
325
+ logger.log(` ID: ${data.application.id}`);
326
+ logger.log(` Key: ${data.application.key}`);
327
+ logger.log(` Display Name: ${data.application.displayName}\n`);
328
+
329
+ logger.log(chalk.bold.yellow('šŸ”‘ CREDENTIALS (save these immediately):'));
330
+ logger.log(chalk.yellow(` Client ID: ${data.credentials.clientId}`));
331
+ logger.log(chalk.yellow(` Client Secret: ${data.credentials.clientSecret}\n`));
332
+
333
+ logger.log(chalk.red('āš ļø IMPORTANT: Client Secret will not be shown again!\n'));
334
+
335
+ const envPrefix = getEnvironmentPrefix(environment);
336
+ logger.log(chalk.bold('šŸ“ Add to GitHub Secrets:'));
337
+ logger.log(chalk.cyan(' Repository level:'));
338
+ logger.log(chalk.cyan(` MISO_CONTROLLER_URL = ${apiUrl}`));
339
+ logger.log(chalk.cyan(`\n Environment level (${environment}):`));
340
+ logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTID = ${data.credentials.clientId}`));
341
+ logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTSECRET = ${data.credentials.clientSecret}\n`));
342
+ }
343
+
344
+ /**
345
+ * Register an application
346
+ * @async
347
+ * @param {string} appKey - Application key
348
+ * @param {Object} options - Registration options
349
+ * @param {string} options.environment - Environment ID or key
350
+ * @param {number} [options.port] - Application port
351
+ * @param {string} [options.name] - Override display name
352
+ * @param {string} [options.description] - Override description
353
+ * @throws {Error} If registration fails
354
+ */
355
+ async function registerApplication(appKey, options) {
356
+ logger.log(chalk.blue('šŸ“‹ Registering application...\n'));
357
+
358
+ // Load variables.yaml
359
+ const { variables, created } = await loadVariablesYaml(appKey);
360
+ let finalVariables = variables;
361
+
362
+ // Create minimal app if needed
363
+ if (created) {
364
+ finalVariables = await createMinimalAppIfNeeded(appKey, options);
365
+ }
366
+
367
+ // Extract configuration
368
+ const appConfig = extractAppConfiguration(finalVariables, appKey, options);
369
+
370
+ // Validate configuration (pass original appKey for error messages)
371
+ validateAppRegistrationData(appConfig, appKey);
372
+
373
+ // Get controller URL from variables.yaml if available
374
+ const controllerUrl = finalVariables?.deployment?.controllerUrl;
375
+
376
+ // Check authentication (try device token first, supports registration flow)
377
+ const authConfig = await checkAuthentication(controllerUrl, options.environment);
378
+
379
+ // Validate environment
380
+ const environment = registerApplicationSchema.environmentId(options.environment);
381
+
382
+ // Prepare registration data to match OpenAPI RegisterApplicationRequest schema
383
+ // Schema: { key, displayName, description?, configuration: { type, registryMode, port?, image? } }
384
+ const registrationData = {
385
+ key: appConfig.appKey,
386
+ displayName: appConfig.displayName,
387
+ configuration: {
388
+ type: appConfig.appType,
389
+ registryMode: appConfig.registryMode
390
+ }
391
+ };
392
+
393
+ // Add optional fields only if they have values
394
+ if (appConfig.description || options.description) {
395
+ registrationData.description = appConfig.description || options.description;
396
+ }
397
+
398
+ if (appConfig.port) {
399
+ registrationData.configuration.port = appConfig.port;
400
+ }
401
+
402
+ // Register application
403
+ const responseData = await callRegisterApi(
404
+ authConfig.apiUrl,
405
+ authConfig.token,
406
+ environment,
407
+ registrationData
408
+ );
409
+
410
+ // Save credentials to local secrets if localhost
411
+ if (isLocalhost(authConfig.apiUrl)) {
412
+ const registeredAppKey = responseData.application.key;
413
+ const clientIdKey = `${registeredAppKey}-client-idKeyVault`;
414
+ const clientSecretKey = `${registeredAppKey}-client-secretKeyVault`;
415
+
416
+ try {
417
+ await saveLocalSecret(clientIdKey, responseData.credentials.clientId);
418
+ await saveLocalSecret(clientSecretKey, responseData.credentials.clientSecret);
419
+
420
+ // Update env.template
421
+ await updateEnvTemplate(registeredAppKey, clientIdKey, clientSecretKey, authConfig.apiUrl);
422
+
423
+ logger.log(chalk.green('\nāœ“ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
424
+ logger.log(chalk.green('āœ“ env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
425
+ } catch (error) {
426
+ logger.warn(chalk.yellow(`āš ļø Could not save credentials locally: ${error.message}`));
427
+ }
428
+ }
429
+
430
+ // Display results
431
+ displayRegistrationResults(responseData, authConfig.apiUrl, environment);
432
+ }
433
+
434
+ module.exports = { registerApplication, getEnvironmentPrefix };
435
+
@@ -0,0 +1,164 @@
1
+ /**
2
+ * AI Fabrix Builder - App Rotate Secret Command
3
+ *
4
+ * Handles rotating pipeline ClientSecret for an application
5
+ *
6
+ * @fileoverview App rotate-secret command implementation for AI Fabrix Builder
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const chalk = require('chalk');
12
+ const { getConfig } = require('./config');
13
+ const { getOrRefreshDeviceToken } = require('./utils/token-manager');
14
+ const { authenticatedApiCall } = require('./utils/api');
15
+ const { formatApiError } = require('./utils/api-error-handler');
16
+ const logger = require('./utils/logger');
17
+ const { saveLocalSecret, isLocalhost } = require('./utils/local-secrets');
18
+ const { updateEnvTemplate } = require('./utils/env-template');
19
+ const { getEnvironmentPrefix } = require('./app-register');
20
+
21
+ /**
22
+ * Validate environment parameter
23
+ * @param {string} environment - Environment ID or key
24
+ * @throws {Error} If environment is invalid
25
+ */
26
+ function validateEnvironment(environment) {
27
+ if (!environment || environment.length < 1) {
28
+ throw new Error('Environment is required');
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Validate API response structure
34
+ * @param {Object} response - API response
35
+ * @throws {Error} If response structure is invalid
36
+ */
37
+ function validateResponse(response) {
38
+ if (!response.data || typeof response.data !== 'object') {
39
+ throw new Error('Invalid response: missing data');
40
+ }
41
+
42
+ const credentials = response.data.credentials;
43
+ if (!credentials || typeof credentials !== 'object' || typeof credentials.clientId !== 'string' || typeof credentials.clientSecret !== 'string') {
44
+ throw new Error('Invalid response: missing or invalid credentials');
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Display rotation results
50
+ * @param {string} appKey - Application key
51
+ * @param {string} environment - Environment ID or key
52
+ * @param {Object} credentials - New credentials
53
+ * @param {string} apiUrl - API URL
54
+ * @param {string} [message] - Optional message from API
55
+ */
56
+ function displayRotationResults(appKey, environment, credentials, apiUrl, message) {
57
+ logger.log(chalk.green('āœ… Secret rotated successfully!\n'));
58
+ logger.log(chalk.bold('šŸ“‹ Application Details:'));
59
+ logger.log(` Key: ${appKey}`);
60
+ logger.log(` Environment: ${environment}\n`);
61
+
62
+ logger.log(chalk.bold.yellow('šŸ”‘ NEW CREDENTIALS:'));
63
+ logger.log(chalk.yellow(` Client ID: ${credentials.clientId}`));
64
+ logger.log(chalk.yellow(` Client Secret: ${credentials.clientSecret}\n`));
65
+
66
+ const envPrefix = getEnvironmentPrefix(environment);
67
+ logger.log(chalk.bold('šŸ“ Update GitHub Secrets:'));
68
+ logger.log(chalk.cyan(' Repository level:'));
69
+ logger.log(chalk.cyan(` MISO_CONTROLLER_URL = ${apiUrl}`));
70
+ logger.log(chalk.cyan(`\n Environment level (${environment}):`));
71
+ logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTID = ${credentials.clientId}`));
72
+ logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTSECRET = ${credentials.clientSecret}\n`));
73
+
74
+ if (message) {
75
+ logger.log(chalk.gray(` ${message}\n`));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Rotate secret for an application
81
+ * @async
82
+ * @param {string} appKey - Application key
83
+ * @param {Object} options - Command options
84
+ * @param {string} options.environment - Environment ID or key
85
+ * @throws {Error} If rotation fails
86
+ */
87
+ async function rotateSecret(appKey, options) {
88
+ logger.log(chalk.yellow('āš ļø This will invalidate the old ClientSecret!\n'));
89
+
90
+ const config = await getConfig();
91
+
92
+ // Try to get device token
93
+ let controllerUrl = null;
94
+ let token = null;
95
+
96
+ if (config.device) {
97
+ const deviceUrls = Object.keys(config.device);
98
+ if (deviceUrls.length > 0) {
99
+ controllerUrl = deviceUrls[0];
100
+ const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
101
+ if (deviceToken && deviceToken.token) {
102
+ token = deviceToken.token;
103
+ controllerUrl = deviceToken.controller;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (!token || !controllerUrl) {
109
+ logger.error(chalk.red('āŒ Not logged in. Run: aifabrix login'));
110
+ logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
111
+ process.exit(1);
112
+ }
113
+
114
+ // Validate environment
115
+ validateEnvironment(options.environment);
116
+
117
+ // OpenAPI spec: POST /api/v1/environments/{envKey}/applications/{appKey}/rotate-secret
118
+ // Path parameters: envKey, appKey (no query parameters)
119
+ const response = await authenticatedApiCall(
120
+ `${controllerUrl}/api/v1/environments/${encodeURIComponent(options.environment)}/applications/${encodeURIComponent(appKey)}/rotate-secret`,
121
+ {
122
+ method: 'POST'
123
+ },
124
+ token
125
+ );
126
+
127
+ if (!response.success) {
128
+ const formattedError = response.formattedError || formatApiError(response);
129
+ logger.error(formattedError);
130
+ process.exit(1);
131
+ }
132
+
133
+ // Validate response structure
134
+ validateResponse(response);
135
+
136
+ const credentials = response.data.credentials;
137
+ const message = response.data.message;
138
+
139
+ // Save credentials to local secrets (always save when rotating)
140
+ const clientIdKey = `${appKey}-client-idKeyVault`;
141
+ const clientSecretKey = `${appKey}-client-secretKeyVault`;
142
+
143
+ try {
144
+ await saveLocalSecret(clientIdKey, credentials.clientId);
145
+ await saveLocalSecret(clientSecretKey, credentials.clientSecret);
146
+
147
+ // Update env.template if localhost
148
+ if (isLocalhost(controllerUrl)) {
149
+ await updateEnvTemplate(appKey, clientIdKey, clientSecretKey, controllerUrl);
150
+ logger.log(chalk.green('\nāœ“ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
151
+ logger.log(chalk.green('āœ“ env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
152
+ } else {
153
+ logger.log(chalk.green('\nāœ“ Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
154
+ }
155
+ } catch (error) {
156
+ logger.warn(chalk.yellow(`āš ļø Could not save credentials locally: ${error.message}`));
157
+ }
158
+
159
+ // Display results
160
+ displayRotationResults(appKey, options.environment, credentials, controllerUrl, message);
161
+ }
162
+
163
+ module.exports = { rotateSecret };
164
+