@aifabrix/builder 2.1.7 → 2.2.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.
@@ -9,246 +9,11 @@
9
9
  * @version 2.0.0
10
10
  */
11
11
 
12
- const fs = require('fs').promises;
13
- const path = require('path');
14
12
  const chalk = require('chalk');
15
- const yaml = require('js-yaml');
16
- const { getConfig } = require('../config');
17
- const { authenticatedApiCall } = require('../utils/api');
18
13
  const logger = require('../utils/logger');
19
- const { saveLocalSecret, isLocalhost } = require('../utils/local-secrets');
20
- const { updateEnvTemplate } = require('../utils/env-template');
21
-
22
- // Import createApp to auto-generate config if missing
23
- let createApp;
24
- try {
25
- createApp = require('../app').createApp;
26
- } catch {
27
- createApp = null;
28
- }
29
-
30
- /**
31
- * Validation schema for application registration
32
- */
33
- const registerApplicationSchema = {
34
- environmentId: (val) => {
35
- if (!val || val.length < 1) {
36
- throw new Error('Invalid environment ID format');
37
- }
38
- return val;
39
- },
40
- key: (val) => {
41
- if (!val || val.length < 1) {
42
- throw new Error('Application key is required');
43
- }
44
- if (val.length > 50) {
45
- throw new Error('Application key must be at most 50 characters');
46
- }
47
- if (!/^[a-z0-9-]+$/.test(val)) {
48
- throw new Error('Application key must contain only lowercase letters, numbers, and hyphens');
49
- }
50
- return val;
51
- },
52
- displayName: (val) => {
53
- if (!val || val.length < 1) {
54
- throw new Error('Display name is required');
55
- }
56
- if (val.length > 100) {
57
- throw new Error('Display name must be at most 100 characters');
58
- }
59
- return val;
60
- },
61
- description: (val) => val || undefined,
62
- configuration: (val) => {
63
- const validTypes = ['webapp', 'api', 'service', 'functionapp'];
64
- const validRegistryModes = ['acr', 'external', 'public'];
65
-
66
- if (!val || !val.type || !validTypes.includes(val.type)) {
67
- throw new Error('Configuration type must be one of: webapp, api, service, functionapp');
68
- }
69
- if (!val.registryMode || !validRegistryModes.includes(val.registryMode)) {
70
- throw new Error('Registry mode must be one of: acr, external, public');
71
- }
72
- if (val.port !== undefined) {
73
- if (!Number.isInteger(val.port) || val.port < 1 || val.port > 65535) {
74
- throw new Error('Port must be an integer between 1 and 65535');
75
- }
76
- }
77
- return val;
78
- }
79
- };
80
-
81
- /**
82
- * Load variables.yaml file for an application
83
- * @async
84
- * @param {string} appKey - Application key
85
- * @returns {Promise<{variables: Object, created: boolean}>} Variables and creation flag
86
- */
87
- async function loadVariablesYaml(appKey) {
88
- const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
89
-
90
- try {
91
- const variablesContent = await fs.readFile(variablesPath, 'utf-8');
92
- return { variables: yaml.load(variablesContent), created: false };
93
- } catch (error) {
94
- if (error.code === 'ENOENT') {
95
- logger.log(chalk.yellow(`āš ļø variables.yaml not found for ${appKey}`));
96
- logger.log(chalk.yellow('šŸ“ Creating minimal configuration...\n'));
97
- return { variables: null, created: true };
98
- }
99
- throw new Error(`Failed to read variables.yaml: ${error.message}`);
100
- }
101
- }
102
-
103
- /**
104
- * Create minimal application configuration if needed
105
- * @async
106
- * @param {string} appKey - Application key
107
- * @param {Object} options - Registration options
108
- * @returns {Promise<Object>} Variables after creation
109
- */
110
- async function createMinimalAppIfNeeded(appKey, options) {
111
- if (!createApp) {
112
- throw new Error('Cannot auto-create application: createApp function not available');
113
- }
114
-
115
- await createApp(appKey, {
116
- port: options.port,
117
- language: 'typescript',
118
- database: false,
119
- redis: false,
120
- storage: false,
121
- authentication: false
122
- });
123
-
124
- const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
125
- const variablesContent = await fs.readFile(variablesPath, 'utf-8');
126
- return yaml.load(variablesContent);
127
- }
128
-
129
- /**
130
- * Extract application configuration from variables.yaml
131
- * @param {Object} variables - Variables from YAML file
132
- * @param {string} appKey - Application key
133
- * @param {Object} options - Registration options
134
- * @returns {Object} Extracted configuration
135
- */
136
- function extractAppConfiguration(variables, appKey, options) {
137
- const appKeyFromFile = variables.app?.key || appKey;
138
- const displayName = variables.app?.name || options.name || appKey;
139
- const description = variables.app?.description || '';
140
- const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
141
- const registryMode = 'external';
142
- const port = variables.build?.port || options.port || 3000;
143
- const language = variables.build?.language || 'typescript';
144
-
145
- return {
146
- appKey: appKeyFromFile,
147
- displayName,
148
- description,
149
- appType,
150
- registryMode,
151
- port,
152
- language
153
- };
154
- }
155
-
156
- /**
157
- * Validate application registration data
158
- * @param {Object} config - Application configuration
159
- * @param {string} originalAppKey - Original app key for error messages
160
- * @throws {Error} If validation fails
161
- */
162
- function validateAppRegistrationData(config, originalAppKey) {
163
- const missingFields = [];
164
- if (!config.appKey) missingFields.push('app.key');
165
- if (!config.displayName) missingFields.push('app.name');
166
-
167
- if (missingFields.length > 0) {
168
- logger.error(chalk.red('āŒ Missing required fields in variables.yaml:'));
169
- missingFields.forEach(field => logger.error(chalk.red(` - ${field}`)));
170
- logger.error(chalk.red(`\n Please update builder/${originalAppKey}/variables.yaml and try again.`));
171
- process.exit(1);
172
- }
173
-
174
- try {
175
- registerApplicationSchema.key(config.appKey);
176
- registerApplicationSchema.displayName(config.displayName);
177
- registerApplicationSchema.configuration({
178
- type: config.appType,
179
- registryMode: config.registryMode,
180
- port: config.port
181
- });
182
- } catch (error) {
183
- logger.error(chalk.red(`āŒ Invalid configuration: ${error.message}`));
184
- process.exit(1);
185
- }
186
- }
187
-
188
- /**
189
- * Check if user is authenticated
190
- * @async
191
- * @returns {Promise<Object>} Configuration with API URL and token
192
- */
193
- async function checkAuthentication() {
194
- const config = await getConfig();
195
- if (!config.apiUrl || !config.token) {
196
- logger.error(chalk.red('āŒ Not logged in. Run: aifabrix login'));
197
- process.exit(1);
198
- }
199
- return config;
200
- }
201
-
202
- /**
203
- * Call registration API
204
- * @async
205
- * @param {string} apiUrl - API URL
206
- * @param {string} token - Authentication token
207
- * @param {string} environment - Environment ID
208
- * @param {Object} registrationData - Registration data
209
- * @returns {Promise<Object>} API response
210
- */
211
- async function registerApplication(apiUrl, token, environment, registrationData) {
212
- const response = await authenticatedApiCall(
213
- `${apiUrl}/api/v1/environments/${encodeURIComponent(environment)}/applications/register`,
214
- {
215
- method: 'POST',
216
- body: JSON.stringify(registrationData)
217
- },
218
- token
219
- );
220
-
221
- if (!response.success) {
222
- logger.error(chalk.red(`āŒ Registration failed: ${response.error}`));
223
- process.exit(1);
224
- }
225
-
226
- return response.data;
227
- }
228
-
229
- /**
230
- * Display registration success and credentials
231
- * @param {Object} data - Registration response data
232
- * @param {string} apiUrl - API URL
233
- */
234
- function displayRegistrationResults(data, apiUrl) {
235
- logger.log(chalk.green('āœ… Application registered successfully!\n'));
236
- logger.log(chalk.bold('šŸ“‹ Application Details:'));
237
- logger.log(` ID: ${data.application.id}`);
238
- logger.log(` Key: ${data.application.key}`);
239
- logger.log(` Display Name: ${data.application.displayName}\n`);
240
-
241
- logger.log(chalk.bold.yellow('šŸ”‘ CREDENTIALS (save these immediately):'));
242
- logger.log(chalk.yellow(` Client ID: ${data.credentials.clientId}`));
243
- logger.log(chalk.yellow(` Client Secret: ${data.credentials.clientSecret}\n`));
244
-
245
- logger.log(chalk.red('āš ļø IMPORTANT: Client Secret will not be shown again!\n'));
246
-
247
- logger.log(chalk.bold('šŸ“ Add to GitHub Secrets:'));
248
- logger.log(chalk.cyan(` AIFABRIX_CLIENT_ID = ${data.credentials.clientId}`));
249
- logger.log(chalk.cyan(` AIFABRIX_CLIENT_SECRET = ${data.credentials.clientSecret}`));
250
- logger.log(chalk.cyan(` AIFABRIX_API_URL = ${apiUrl}\n`));
251
- }
14
+ const { listApplications } = require('../app-list');
15
+ const { registerApplication } = require('../app-register');
16
+ const { rotateSecret } = require('../app-rotate-secret');
252
17
 
253
18
  /**
254
19
  * Setup application management commands
@@ -269,74 +34,7 @@ function setupAppCommands(program) {
269
34
  .option('-d, --description <desc>', 'Override description')
270
35
  .action(async(appKey, options) => {
271
36
  try {
272
- logger.log(chalk.blue('šŸ“‹ Registering application...\n'));
273
-
274
- // Load variables.yaml
275
- const { variables, created } = await loadVariablesYaml(appKey);
276
- let finalVariables = variables;
277
-
278
- // Create minimal app if needed
279
- if (created) {
280
- finalVariables = await createMinimalAppIfNeeded(appKey, options);
281
- }
282
-
283
- // Extract configuration
284
- const appConfig = extractAppConfiguration(finalVariables, appKey, options);
285
-
286
- // Validate configuration (pass original appKey for error messages)
287
- validateAppRegistrationData(appConfig, appKey);
288
-
289
- // Check authentication
290
- const config = await checkAuthentication();
291
-
292
- // Validate environment
293
- const environment = registerApplicationSchema.environmentId(options.environment);
294
-
295
- // Prepare registration data
296
- const registrationData = {
297
- environmentId: environment,
298
- key: appConfig.appKey,
299
- displayName: appConfig.displayName,
300
- description: appConfig.description || options.description,
301
- configuration: {
302
- type: appConfig.appType,
303
- registryMode: appConfig.registryMode,
304
- port: appConfig.port,
305
- language: appConfig.language
306
- }
307
- };
308
-
309
- // Register application
310
- const responseData = await registerApplication(
311
- config.apiUrl,
312
- config.token,
313
- environment,
314
- registrationData
315
- );
316
-
317
- // Save credentials to local secrets if localhost
318
- if (isLocalhost(config.apiUrl)) {
319
- const appKey = responseData.application.key;
320
- const clientIdKey = `${appKey}-client-idKeyVault`;
321
- const clientSecretKey = `${appKey}-client-secretKeyVault`;
322
-
323
- try {
324
- await saveLocalSecret(clientIdKey, responseData.credentials.clientId);
325
- await saveLocalSecret(clientSecretKey, responseData.credentials.clientSecret);
326
-
327
- // Update env.template
328
- await updateEnvTemplate(appKey, clientIdKey, clientSecretKey);
329
-
330
- logger.log(chalk.green('\nāœ“ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
331
- logger.log(chalk.green('āœ“ env.template updated with MISO_CLIENTID and MISO_CLIENTSECRET\n'));
332
- } catch (error) {
333
- logger.warn(chalk.yellow(`āš ļø Could not save credentials locally: ${error.message}`));
334
- }
335
- }
336
-
337
- // Display results
338
- displayRegistrationResults(responseData, config.apiUrl);
339
-
37
+ await registerApplication(appKey, options);
340
38
  } catch (error) {
341
39
  logger.error(chalk.red('āŒ Registration failed:'), error.message);
342
40
  process.exit(1);
@@ -350,30 +48,7 @@ function setupAppCommands(program) {
350
48
  .requiredOption('-e, --environment <env>', 'Environment ID or key')
351
49
  .action(async(options) => {
352
50
  try {
353
- const config = await getConfig();
354
- if (!config.apiUrl || !config.token) {
355
- logger.error(chalk.red('āŒ Not logged in. Run: aifabrix login'));
356
- process.exit(1);
357
- }
358
-
359
- const response = await authenticatedApiCall(
360
- `${config.apiUrl}/api/v1/applications?environmentId=${options.environment}`,
361
- {},
362
- config.token
363
- );
364
-
365
- if (!response.success || !response.data) {
366
- logger.error(chalk.red('āŒ Failed to fetch applications'));
367
- process.exit(1);
368
- }
369
-
370
- logger.log(chalk.bold('\nšŸ“± Applications:\n'));
371
- response.data.forEach((app) => {
372
- const hasPipeline = app.configuration?.pipeline?.isActive ? 'āœ“' : 'āœ—';
373
- logger.log(`${hasPipeline} ${chalk.cyan(app.key)} - ${app.displayName} (${app.status})`);
374
- });
375
- logger.log('');
376
-
51
+ await listApplications(options);
377
52
  } catch (error) {
378
53
  logger.error(chalk.red('āŒ Failed to list applications:'), error.message);
379
54
  process.exit(1);
@@ -382,70 +57,12 @@ function setupAppCommands(program) {
382
57
 
383
58
  // Rotate secret command
384
59
  app
385
- .command('rotate-secret')
60
+ .command('rotate-secret <appKey>')
386
61
  .description('Rotate pipeline ClientSecret for an application')
387
- .requiredOption('-a, --app <appKey>', 'Application key')
388
62
  .requiredOption('-e, --environment <env>', 'Environment ID or key')
389
- .action(async(options) => {
63
+ .action(async(appKey, options) => {
390
64
  try {
391
- logger.log(chalk.yellow('āš ļø This will invalidate the old ClientSecret!\n'));
392
-
393
- const config = await getConfig();
394
- if (!config.apiUrl || !config.token) {
395
- logger.error(chalk.red('āŒ Not logged in. Run: aifabrix login'));
396
- process.exit(1);
397
- }
398
-
399
- // Validate environment
400
- if (!options.environment || options.environment.length < 1) {
401
- logger.error(chalk.red('āŒ Environment is required'));
402
- process.exit(1);
403
- }
404
-
405
- const response = await authenticatedApiCall(
406
- `${config.apiUrl}/api/v1/applications/${options.app}/rotate-secret?environmentId=${options.environment}`,
407
- {
408
- method: 'POST'
409
- },
410
- config.token
411
- );
412
-
413
- if (!response.success) {
414
- logger.error(chalk.red(`āŒ Rotation failed: ${response.error}`));
415
- process.exit(1);
416
- }
417
-
418
- logger.log(chalk.green('āœ… Secret rotated successfully!\n'));
419
- logger.log(chalk.bold('šŸ“‹ Application Details:'));
420
- logger.log(` Key: ${response.data.application?.key || options.app}`);
421
- logger.log(` Environment: ${options.environment}\n`);
422
-
423
- logger.log(chalk.bold.yellow('šŸ”‘ NEW CREDENTIALS:'));
424
- logger.log(chalk.yellow(` Client ID: ${response.data.credentials.clientId}`));
425
- logger.log(chalk.yellow(` Client Secret: ${response.data.credentials.clientSecret}\n`));
426
- logger.log(chalk.red('āš ļø Old secret is now invalid. Update GitHub Secrets!\n'));
427
-
428
- // Save credentials to local secrets if localhost
429
- if (isLocalhost(config.apiUrl)) {
430
- const appKey = response.data.application?.key || options.app;
431
- const clientIdKey = `${appKey}-client-idKeyVault`;
432
- const clientSecretKey = `${appKey}-client-secretKeyVault`;
433
-
434
- try {
435
- await saveLocalSecret(clientIdKey, response.data.credentials.clientId);
436
- await saveLocalSecret(clientSecretKey, response.data.credentials.clientSecret);
437
-
438
- // Update env.template
439
- await updateEnvTemplate(appKey, clientIdKey, clientSecretKey);
440
-
441
- logger.log(chalk.green('āœ“ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
442
- logger.log(chalk.green('āœ“ env.template updated with MISO_CLIENTID and MISO_CLIENTSECRET'));
443
- logger.log('');
444
- } catch (error) {
445
- logger.warn(chalk.yellow(`āš ļø Could not save credentials locally: ${error.message}`));
446
- }
447
- }
448
-
65
+ await rotateSecret(appKey, options);
449
66
  } catch (error) {
450
67
  logger.error(chalk.red('āŒ Rotation failed:'), error.message);
451
68
  process.exit(1);
@@ -12,8 +12,10 @@
12
12
  const inquirer = require('inquirer');
13
13
  const chalk = require('chalk');
14
14
  const ora = require('ora');
15
- const { saveConfig } = require('../config');
15
+ const { setCurrentEnvironment, saveDeviceToken, saveClientToken } = require('../config');
16
16
  const { makeApiCall, initiateDeviceCodeFlow, pollDeviceCodeToken, displayDeviceCodeInfo } = require('../utils/api');
17
+ const { formatApiError } = require('../utils/api-error-handler');
18
+ const { loadClientCredentials } = require('../utils/token-manager');
17
19
  const logger = require('../utils/logger');
18
20
 
19
21
  /**
@@ -118,7 +120,7 @@ async function getEnvironmentKey(environment) {
118
120
  const envPrompt = await inquirer.prompt([{
119
121
  type: 'input',
120
122
  name: 'environment',
121
- message: 'Environment key (e.g., dev, tst, pro):',
123
+ message: 'Environment key (e.g., miso, dev, tst, pro):',
122
124
  validate: (input) => {
123
125
  if (!input || input.trim().length === 0) {
124
126
  return 'Environment key is required';
@@ -132,50 +134,112 @@ async function getEnvironmentKey(environment) {
132
134
  }
133
135
 
134
136
  /**
135
- * Save login configuration
137
+ * Save device token configuration (root level, controller-specific)
138
+ * @async
139
+ * @param {string} controllerUrl - Controller URL (used as key)
140
+ * @param {string} token - Authentication token
141
+ * @param {string} refreshToken - Refresh token for token renewal
142
+ * @param {string} expiresAt - Token expiration time
143
+ */
144
+ async function saveDeviceLoginConfig(controllerUrl, token, refreshToken, expiresAt) {
145
+ await saveDeviceToken(controllerUrl, token, refreshToken, expiresAt);
146
+ }
147
+
148
+ /**
149
+ * Save client credentials token configuration
136
150
  * @async
137
151
  * @param {string} controllerUrl - Controller URL
138
152
  * @param {string} token - Authentication token
139
153
  * @param {string} expiresAt - Token expiration time
140
- * @param {string} [environment] - Environment key
154
+ * @param {string} environment - Environment key
155
+ * @param {string} appName - Application name
141
156
  */
142
- async function saveLoginConfig(controllerUrl, token, expiresAt, environment) {
143
- await saveConfig({
144
- apiUrl: controllerUrl,
145
- token: token,
146
- expiresAt: expiresAt,
147
- environment: environment || undefined
148
- });
157
+ async function saveCredentialsLoginConfig(controllerUrl, token, expiresAt, environment, appName) {
158
+ await saveClientToken(environment, appName, controllerUrl, token, expiresAt);
149
159
  }
150
160
 
151
161
  /**
152
162
  * Handle credentials-based login
163
+ * Uses OpenAPI /api/v1/auth/token endpoint with x-client-id and x-client-secret headers
164
+ * Reads credentials from secrets.local.yaml using pattern <app-name>-client-idKeyVault
153
165
  * @async
154
166
  * @param {string} controllerUrl - Controller URL
155
- * @param {string} [clientId] - Client ID from options
156
- * @param {string} [clientSecret] - Client Secret from options
167
+ * @param {string} appName - Application name
168
+ * @param {string} [clientId] - Client ID from options (optional, overrides secrets.local.yaml)
169
+ * @param {string} [clientSecret] - Client Secret from options (optional, overrides secrets.local.yaml)
157
170
  * @returns {Promise<string>} Authentication token
158
171
  */
159
- async function handleCredentialsLogin(controllerUrl, clientId, clientSecret) {
160
- const credentials = await promptForCredentials(clientId, clientSecret);
172
+ async function handleCredentialsLogin(controllerUrl, appName, clientId, clientSecret) {
173
+ let credentials;
174
+
175
+ // Try to load from secrets.local.yaml if appName provided and credentials not provided
176
+ if (appName && !clientId && !clientSecret) {
177
+ credentials = await loadClientCredentials(appName);
178
+ if (!credentials) {
179
+ logger.log(chalk.yellow(`āš ļø Credentials not found in secrets.local.yaml for app '${appName}'`));
180
+ logger.log(chalk.gray(` Looking for: '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`));
181
+ logger.log(chalk.gray(' Prompting for credentials...\n'));
182
+ }
183
+ }
184
+
185
+ // If still no credentials, prompt for them
186
+ if (!credentials) {
187
+ credentials = await promptForCredentials(clientId, clientSecret);
188
+ }
161
189
 
162
- const response = await makeApiCall(`${controllerUrl}/api/v1/auth/login`, {
190
+ // OpenAPI spec: POST /api/v1/auth/token with x-client-id and x-client-secret headers
191
+ // Response: { success: boolean, token: string, expiresIn: number, expiresAt: string, timestamp: string }
192
+ const response = await makeApiCall(`${controllerUrl}/api/v1/auth/token`, {
163
193
  method: 'POST',
164
194
  headers: {
165
- 'Content-Type': 'application/json'
166
- },
167
- body: JSON.stringify({
168
- clientId: credentials.clientId,
169
- clientSecret: credentials.clientSecret
170
- })
195
+ 'Content-Type': 'application/json',
196
+ 'x-client-id': credentials.clientId,
197
+ 'x-client-secret': credentials.clientSecret
198
+ }
171
199
  });
172
200
 
173
201
  if (!response.success) {
174
- logger.error(chalk.red(`āŒ Login failed: ${response.error}`));
202
+ const formattedError = response.formattedError || formatApiError(response);
203
+ logger.error(formattedError);
204
+
205
+ // Provide additional context for login failures
206
+ if (response.status === 401) {
207
+ logger.log(chalk.gray('\nšŸ’” Tip: Verify your client credentials are correct.'));
208
+ logger.log(chalk.gray(' Check secrets.local.yaml for:'));
209
+ logger.log(chalk.gray(` - ${appName}-client-idKeyVault`));
210
+ logger.log(chalk.gray(` - ${appName}-client-secretKeyVault`));
211
+ }
212
+
213
+ process.exit(1);
214
+ }
215
+
216
+ // OpenAPI spec response: { success: boolean, token: string, expiresIn: number, expiresAt: string, ... }
217
+ // Handle both flat and nested response structures (some APIs wrap in data field)
218
+ const apiResponse = response.data;
219
+ const responseData = apiResponse.data || apiResponse;
220
+
221
+ if (!responseData || !responseData.token) {
222
+ logger.error(chalk.red('āŒ Invalid response: missing token'));
223
+ if (responseData) {
224
+ logger.error(chalk.gray(`Response structure: ${JSON.stringify(responseData, null, 2)}`));
225
+ }
175
226
  process.exit(1);
176
227
  }
177
228
 
178
- return response.data.token || response.data.accessToken;
229
+ // Calculate expiration (use expiresAt if provided, otherwise calculate from expiresIn, default to 24 hours)
230
+ let expiresAt;
231
+ if (responseData.expiresAt) {
232
+ expiresAt = responseData.expiresAt;
233
+ } else if (responseData.expiresIn) {
234
+ expiresAt = new Date(Date.now() + responseData.expiresIn * 1000).toISOString();
235
+ } else {
236
+ expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
237
+ }
238
+
239
+ return {
240
+ token: responseData.token,
241
+ expiresAt: expiresAt
242
+ };
179
243
  }
180
244
 
181
245
  /**
@@ -212,12 +276,22 @@ async function pollAndSaveDeviceCodeToken(controllerUrl, deviceCode, interval, e
212
276
  spinner.succeed('Authentication approved!');
213
277
 
214
278
  const token = tokenResponse.access_token;
279
+ const refreshToken = tokenResponse.refresh_token;
215
280
  const expiresAt = new Date(Date.now() + (tokenResponse.expires_in * 1000)).toISOString();
216
281
 
217
- await saveLoginConfig(controllerUrl, token, expiresAt, envKey);
282
+ // Save device token at root level (controller-specific, not environment-specific)
283
+ await saveDeviceLoginConfig(controllerUrl, token, refreshToken, expiresAt);
284
+
285
+ // Still set current environment if provided (for other purposes)
286
+ if (envKey) {
287
+ await setCurrentEnvironment(envKey);
288
+ }
218
289
 
219
290
  logger.log(chalk.green('\nāœ… Successfully logged in!'));
220
291
  logger.log(chalk.gray(`Controller: ${controllerUrl}`));
292
+ if (envKey) {
293
+ logger.log(chalk.gray(`Environment: ${envKey}`));
294
+ }
221
295
  logger.log(chalk.gray('Token stored securely in ~/.aifabrix/config.yaml\n'));
222
296
 
223
297
  return { token, environment: envKey };
@@ -264,26 +338,46 @@ async function handleDeviceCodeLogin(controllerUrl, environment) {
264
338
  * @async
265
339
  * @function handleLogin
266
340
  * @param {Object} options - Login options
267
- * @param {string} [options.url] - Controller URL (default: 'http://localhost:3000')
341
+ * @param {string} [options.controller] - Controller URL (default: 'http://localhost:3000')
268
342
  * @param {string} [options.method] - Authentication method ('device' or 'credentials')
269
- * @param {string} [options.clientId] - Client ID (for credentials method)
270
- * @param {string} [options.clientSecret] - Client Secret (for credentials method)
271
- * @param {string} [options.environment] - Environment key (for device method)
343
+ * @param {string} [options.app] - Application name (for credentials method, reads from secrets.local.yaml)
344
+ * @param {string} [options.clientId] - Client ID (for credentials method, overrides secrets.local.yaml)
345
+ * @param {string} [options.clientSecret] - Client Secret (for credentials method, overrides secrets.local.yaml)
346
+ * @param {string} [options.environment] - Environment key (updates root-level environment in config.yaml)
272
347
  * @returns {Promise<void>} Resolves when login completes
273
348
  * @throws {Error} If login fails
274
349
  */
275
350
  async function handleLogin(options) {
276
351
  logger.log(chalk.blue('\nšŸ” Logging in to Miso Controller...\n'));
277
352
 
278
- const controllerUrl = options.url.replace(/\/$/, '');
353
+ const controllerUrl = (options.controller || options.url || 'http://localhost:3000').replace(/\/$/, '');
279
354
  logger.log(chalk.gray(`Controller URL: ${controllerUrl}`));
280
355
 
356
+ // Update root-level environment if provided
357
+ let environment = null;
358
+ if (options.environment) {
359
+ environment = options.environment.trim();
360
+ await setCurrentEnvironment(environment);
361
+ logger.log(chalk.gray(`Environment: ${environment}`));
362
+ } else {
363
+ // Get current environment from config
364
+ const { getCurrentEnvironment } = require('../config');
365
+ environment = await getCurrentEnvironment();
366
+ }
367
+
281
368
  const method = await determineAuthMethod(options.method);
282
369
  let token;
283
- let environment = null;
370
+ let expiresAt;
284
371
 
285
372
  if (method === 'credentials') {
286
- token = await handleCredentialsLogin(controllerUrl, options.clientId, options.clientSecret);
373
+ if (!options.app) {
374
+ logger.error(chalk.red('āŒ --app is required for credentials login method'));
375
+ process.exit(1);
376
+ }
377
+ const loginResult = await handleCredentialsLogin(controllerUrl, options.app, options.clientId, options.clientSecret);
378
+ token = loginResult.token;
379
+ expiresAt = loginResult.expiresAt;
380
+ await saveCredentialsLoginConfig(controllerUrl, token, expiresAt, environment, options.app);
287
381
  } else if (method === 'device') {
288
382
  const result = await handleDeviceCodeLogin(controllerUrl, options.environment);
289
383
  token = result.token;
@@ -291,12 +385,12 @@ async function handleLogin(options) {
291
385
  return; // Early return for device flow (already saved config)
292
386
  }
293
387
 
294
- // Save configuration for credentials method
295
- const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
296
- await saveLoginConfig(controllerUrl, token, expiresAt, environment);
297
-
298
388
  logger.log(chalk.green('\nāœ… Successfully logged in!'));
299
389
  logger.log(chalk.gray(`Controller: ${controllerUrl}`));
390
+ logger.log(chalk.gray(`Environment: ${environment}`));
391
+ if (options.app) {
392
+ logger.log(chalk.gray(`App: ${options.app}`));
393
+ }
300
394
  logger.log(chalk.gray('Token stored securely in ~/.aifabrix/config.yaml\n'));
301
395
  }
302
396