@aifabrix/builder 2.7.0 → 2.8.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.
package/lib/app-deploy.js CHANGED
@@ -366,6 +366,24 @@ async function deployApp(appName, options = {}) {
366
366
 
367
367
  validateAppName(appName);
368
368
 
369
+ // 2. Check if app type is external - route to external deployment
370
+ const yaml = require('js-yaml');
371
+ const fs = require('fs').promises;
372
+ const path = require('path');
373
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
374
+ try {
375
+ const variablesContent = await fs.readFile(variablesPath, 'utf8');
376
+ const variables = yaml.load(variablesContent);
377
+ if (variables.app && variables.app.type === 'external') {
378
+ const externalDeploy = require('./external-system-deploy');
379
+ await externalDeploy.deployExternalSystem(appName, options);
380
+ return { success: true, type: 'external' };
381
+ }
382
+ } catch (error) {
383
+ // If variables.yaml doesn't exist or can't be read, continue with normal deployment
384
+ // The error will be properly handled in loadDeploymentConfig
385
+ }
386
+
369
387
  // 2. Load deployment configuration
370
388
  config = await loadDeploymentConfig(appName, options);
371
389
  controllerUrl = config.controllerUrl || options.controller || 'unknown';
@@ -84,6 +84,21 @@ async function generateAndCopyDockerfile(appPath, dockerfilePath, config) {
84
84
  * @returns {Promise<string>} Path to generated Dockerfile
85
85
  */
86
86
  async function generateDockerfileForApp(appName, options = {}) {
87
+ // Check if app type is external - skip Dockerfile generation
88
+ const configPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
89
+ try {
90
+ const yamlContent = await fs.readFile(configPath, 'utf8');
91
+ const variables = yaml.load(yamlContent);
92
+ if (variables.app && variables.app.type === 'external') {
93
+ logger.log(chalk.yellow('⚠️ External systems don\'t require Dockerfiles. Skipping...'));
94
+ return null;
95
+ }
96
+ } catch (error) {
97
+ // If variables.yaml doesn't exist, continue with normal generation
98
+ if (error.code !== 'ENOENT') {
99
+ throw error;
100
+ }
101
+ }
87
102
  try {
88
103
  // Validate app name
89
104
  validateAppName(appName);
@@ -13,11 +13,17 @@ const inquirer = require('inquirer');
13
13
  /**
14
14
  * Builds basic questions (port, language)
15
15
  * @param {Object} options - Provided options
16
+ * @param {string} [appType] - Application type (webapp, api, service, functionapp, external)
16
17
  * @returns {Array} Array of question objects
17
18
  */
18
- function buildBasicQuestions(options) {
19
+ function buildBasicQuestions(options, appType) {
19
20
  const questions = [];
20
21
 
22
+ // Skip port and language for external type
23
+ if (appType === 'external') {
24
+ return questions;
25
+ }
26
+
21
27
  // Port validation
22
28
  if (!options.port) {
23
29
  questions.push({
@@ -55,11 +61,17 @@ function buildBasicQuestions(options) {
55
61
  /**
56
62
  * Builds service questions (database, redis, storage, authentication)
57
63
  * @param {Object} options - Provided options
64
+ * @param {string} [appType] - Application type (webapp, api, service, functionapp, external)
58
65
  * @returns {Array} Array of question objects
59
66
  */
60
- function buildServiceQuestions(options) {
67
+ function buildServiceQuestions(options, appType) {
61
68
  const questions = [];
62
69
 
70
+ // Skip service questions for external type
71
+ if (appType === 'external') {
72
+ return questions;
73
+ }
74
+
63
75
  if (!Object.prototype.hasOwnProperty.call(options, 'database')) {
64
76
  questions.push({
65
77
  type: 'confirm',
@@ -99,6 +111,116 @@ function buildServiceQuestions(options) {
99
111
  return questions;
100
112
  }
101
113
 
114
+ /**
115
+ * Builds external system configuration questions
116
+ * @param {Object} options - Provided options
117
+ * @param {string} appName - Application name
118
+ * @returns {Array} Array of question objects
119
+ */
120
+ function buildExternalSystemQuestions(options, appName) {
121
+ const questions = [];
122
+
123
+ // System key (defaults to app name)
124
+ if (!options.systemKey) {
125
+ questions.push({
126
+ type: 'input',
127
+ name: 'systemKey',
128
+ message: 'What is the system key?',
129
+ default: appName,
130
+ validate: (input) => {
131
+ if (!input || input.trim().length === 0) {
132
+ return 'System key is required';
133
+ }
134
+ if (!/^[a-z0-9-]+$/.test(input)) {
135
+ return 'System key must contain only lowercase letters, numbers, and hyphens';
136
+ }
137
+ return true;
138
+ }
139
+ });
140
+ }
141
+
142
+ // System display name
143
+ if (!options.systemDisplayName) {
144
+ questions.push({
145
+ type: 'input',
146
+ name: 'systemDisplayName',
147
+ message: 'What is the system display name?',
148
+ default: appName.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
149
+ validate: (input) => {
150
+ if (!input || input.trim().length === 0) {
151
+ return 'System display name is required';
152
+ }
153
+ return true;
154
+ }
155
+ });
156
+ }
157
+
158
+ // System description
159
+ if (!options.systemDescription) {
160
+ questions.push({
161
+ type: 'input',
162
+ name: 'systemDescription',
163
+ message: 'What is the system description?',
164
+ default: `External system integration for ${appName}`,
165
+ validate: (input) => {
166
+ if (!input || input.trim().length === 0) {
167
+ return 'System description is required';
168
+ }
169
+ return true;
170
+ }
171
+ });
172
+ }
173
+
174
+ // System type
175
+ if (!options.systemType) {
176
+ questions.push({
177
+ type: 'list',
178
+ name: 'systemType',
179
+ message: 'What is the system type?',
180
+ choices: [
181
+ { name: 'OpenAPI', value: 'openapi' },
182
+ { name: 'MCP (Model Context Protocol)', value: 'mcp' },
183
+ { name: 'Custom', value: 'custom' }
184
+ ],
185
+ default: 'openapi'
186
+ });
187
+ }
188
+
189
+ // Authentication type
190
+ if (!options.authType) {
191
+ questions.push({
192
+ type: 'list',
193
+ name: 'authType',
194
+ message: 'What authentication type does the system use?',
195
+ choices: [
196
+ { name: 'OAuth2', value: 'oauth2' },
197
+ { name: 'API Key', value: 'apikey' },
198
+ { name: 'Basic Auth', value: 'basic' }
199
+ ],
200
+ default: 'apikey'
201
+ });
202
+ }
203
+
204
+ // Number of datasources
205
+ if (!options.datasourceCount) {
206
+ questions.push({
207
+ type: 'input',
208
+ name: 'datasourceCount',
209
+ message: 'How many datasources do you want to create?',
210
+ default: '1',
211
+ validate: (input) => {
212
+ const count = parseInt(input, 10);
213
+ if (isNaN(count) || count < 1 || count > 10) {
214
+ return 'Datasource count must be a number between 1 and 10';
215
+ }
216
+ return true;
217
+ }
218
+ });
219
+ }
220
+
221
+ return questions;
222
+ }
223
+
102
224
  /**
103
225
  * Builds workflow questions (GitHub, Controller)
104
226
  * @param {Object} options - Provided options
@@ -186,7 +308,7 @@ function resolveOptionalBoolean(optionValue, answerValue, defaultValue) {
186
308
  * @returns {Object} Resolved configuration
187
309
  */
188
310
  function resolveConflicts(options, answers) {
189
- return {
311
+ const config = {
190
312
  port: parseInt(resolveField(options.port, answers.port, 3000), 10),
191
313
  language: resolveField(options.language, answers.language, 'typescript'),
192
314
  database: resolveField(options.database, answers.database, false),
@@ -197,6 +319,28 @@ function resolveConflicts(options, answers) {
197
319
  controller: resolveOptionalBoolean(options.controller, answers.controller, false),
198
320
  controllerUrl: resolveField(options.controllerUrl, answers.controllerUrl, undefined)
199
321
  };
322
+
323
+ // Add external system fields if present
324
+ if (answers.systemKey || options.systemKey) {
325
+ config.systemKey = resolveField(options.systemKey, answers.systemKey, undefined);
326
+ }
327
+ if (answers.systemDisplayName || options.systemDisplayName) {
328
+ config.systemDisplayName = resolveField(options.systemDisplayName, answers.systemDisplayName, undefined);
329
+ }
330
+ if (answers.systemDescription || options.systemDescription) {
331
+ config.systemDescription = resolveField(options.systemDescription, answers.systemDescription, undefined);
332
+ }
333
+ if (answers.systemType || options.systemType) {
334
+ config.systemType = resolveField(options.systemType, answers.systemType, 'openapi');
335
+ }
336
+ if (answers.authType || options.authType) {
337
+ config.authType = resolveField(options.authType, answers.authType, 'apikey');
338
+ }
339
+ if (answers.datasourceCount || options.datasourceCount) {
340
+ config.datasourceCount = parseInt(resolveField(options.datasourceCount, answers.datasourceCount, 1), 10);
341
+ }
342
+
343
+ return config;
200
344
  }
201
345
 
202
346
  /**
@@ -225,17 +369,36 @@ async function promptForOptions(appName, options) {
225
369
  options.github = false;
226
370
  }
227
371
 
228
- const questions = [
229
- ...buildBasicQuestions(options),
230
- ...buildServiceQuestions(options),
231
- ...buildWorkflowQuestions(options)
232
- ];
372
+ // Get app type from options (default to webapp)
373
+ const appType = options.type || 'webapp';
374
+
375
+ // Build questions based on app type
376
+ let questions = [];
377
+ if (appType === 'external') {
378
+ // For external type, prompt for external system configuration
379
+ questions = [
380
+ ...buildExternalSystemQuestions(options, appName),
381
+ ...buildWorkflowQuestions(options)
382
+ ];
383
+ } else {
384
+ // For regular apps, use standard prompts
385
+ questions = [
386
+ ...buildBasicQuestions(options, appType),
387
+ ...buildServiceQuestions(options, appType),
388
+ ...buildWorkflowQuestions(options)
389
+ ];
390
+ }
233
391
 
234
392
  // Prompt for missing options
235
393
  const answers = questions.length > 0 ? await inquirer.prompt(questions) : {};
236
394
 
237
395
  // Merge provided options with answers
238
- return mergePromptAnswers(appName, options, answers);
396
+ const merged = mergePromptAnswers(appName, options, answers);
397
+
398
+ // Add type to merged config
399
+ merged.type = appType;
400
+
401
+ return merged;
239
402
  }
240
403
 
241
404
  module.exports = {
package/lib/app-push.js CHANGED
@@ -179,6 +179,21 @@ function displayPushResults(registry, imageName, tags) {
179
179
  * @returns {Promise<void>} Resolves when push is complete
180
180
  */
181
181
  async function pushApp(appName, options = {}) {
182
+ // Check if app type is external - skip push
183
+ const configPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
184
+ try {
185
+ const yamlContent = await fs.readFile(configPath, 'utf8');
186
+ const config = yaml.load(yamlContent);
187
+ if (config.app && config.app.type === 'external') {
188
+ logger.log(chalk.yellow('⚠️ External systems don\'t require Docker images. Skipping push...'));
189
+ return;
190
+ }
191
+ } catch (error) {
192
+ // If variables.yaml doesn't exist, continue with normal push
193
+ if (error.code !== 'ENOENT') {
194
+ throw error;
195
+ }
196
+ }
182
197
  try {
183
198
  // Validate app name
184
199
  validateAppName(appName);
@@ -138,6 +138,20 @@ function extractAppConfiguration(variables, appKey, options) {
138
138
  const appKeyFromFile = variables.app?.key || appKey;
139
139
  const displayName = variables.app?.name || options.name || appKey;
140
140
  const description = variables.app?.description || '';
141
+
142
+ // Handle external type
143
+ if (variables.app?.type === 'external') {
144
+ return {
145
+ appKey: appKeyFromFile,
146
+ displayName,
147
+ description,
148
+ appType: 'external',
149
+ registryMode: 'external',
150
+ port: null, // External systems don't need ports
151
+ language: null // External systems don't need language
152
+ };
153
+ }
154
+
141
155
  const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
142
156
  const registryMode = 'external';
143
157
  const port = variables.build?.port || options.port || 3000;
package/lib/app-run.js CHANGED
@@ -43,6 +43,31 @@ async function runApp(appName, options = {}) {
43
43
  }
44
44
 
45
45
  try {
46
+ // Validate app name first
47
+ if (!appName || typeof appName !== 'string') {
48
+ throw new Error('Application name is required');
49
+ }
50
+
51
+ // Check if app type is external - skip Docker run
52
+ const yaml = require('js-yaml');
53
+ const fs = require('fs').promises;
54
+ const path = require('path');
55
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
56
+ try {
57
+ const variablesContent = await fs.readFile(variablesPath, 'utf8');
58
+ const variables = yaml.load(variablesContent);
59
+ if (variables.app && variables.app.type === 'external') {
60
+ logger.log(chalk.yellow('⚠️ External systems don\'t run as Docker containers.'));
61
+ logger.log(chalk.blue('Use "aifabrix build" to deploy to dataplane, then test via OpenAPI endpoints.'));
62
+ return;
63
+ }
64
+ } catch (error) {
65
+ // If variables.yaml doesn't exist, continue with normal run
66
+ if (error.code !== 'ENOENT') {
67
+ throw error;
68
+ }
69
+ }
70
+
46
71
  // Validate app name and load configuration
47
72
  const appConfig = await helpers.validateAppConfiguration(appName);
48
73
 
package/lib/app.js CHANGED
@@ -38,20 +38,31 @@ function displaySuccessMessage(appName, config, envConversionMessage, hasAppFile
38
38
  if (hasAppFiles) {
39
39
  logger.log(chalk.blue(`Application files: apps/${appName}/`));
40
40
  }
41
- logger.log(chalk.blue(`Language: ${config.language}`));
42
- logger.log(chalk.blue(`Port: ${config.port}`));
43
41
 
44
- if (config.database) logger.log(chalk.yellow(' - Database enabled'));
45
- if (config.redis) logger.log(chalk.yellow(' - Redis enabled'));
46
- if (config.storage) logger.log(chalk.yellow(' - Storage enabled'));
47
- if (config.authentication) logger.log(chalk.yellow(' - Authentication enabled'));
48
-
49
- logger.log(chalk.gray(envConversionMessage));
50
-
51
- logger.log(chalk.green('\nNext steps:'));
52
- logger.log(chalk.white('1. Copy env.template to .env and fill in your values'));
53
- logger.log(chalk.white('2. Run: aifabrix build ' + appName));
54
- logger.log(chalk.white('3. Run: aifabrix run ' + appName));
42
+ if (config.type === 'external') {
43
+ logger.log(chalk.blue('Type: External System'));
44
+ logger.log(chalk.blue(`System Key: ${config.systemKey || appName}`));
45
+ logger.log(chalk.green('\nNext steps:'));
46
+ logger.log(chalk.white('1. Edit external system JSON files in builder/' + appName + '/schemas/'));
47
+ logger.log(chalk.white('2. Run: aifabrix app register ' + appName + ' --environment dev'));
48
+ logger.log(chalk.white('3. Run: aifabrix build ' + appName + ' (deploys to dataplane)'));
49
+ logger.log(chalk.white('4. Run: aifabrix deploy ' + appName + ' (publishes to dataplane)'));
50
+ } else {
51
+ logger.log(chalk.blue(`Language: ${config.language}`));
52
+ logger.log(chalk.blue(`Port: ${config.port}`));
53
+
54
+ if (config.database) logger.log(chalk.yellow(' - Database enabled'));
55
+ if (config.redis) logger.log(chalk.yellow(' - Redis enabled'));
56
+ if (config.storage) logger.log(chalk.yellow(' - Storage enabled'));
57
+ if (config.authentication) logger.log(chalk.yellow(' - Authentication enabled'));
58
+
59
+ logger.log(chalk.gray(envConversionMessage));
60
+
61
+ logger.log(chalk.green('\nNext steps:'));
62
+ logger.log(chalk.white('1. Copy env.template to .env and fill in your values'));
63
+ logger.log(chalk.white('2. Run: aifabrix build ' + appName));
64
+ logger.log(chalk.white('3. Run: aifabrix run ' + appName));
65
+ }
55
66
  }
56
67
 
57
68
  /**
@@ -284,6 +295,12 @@ async function createApp(appName, options = {}) {
284
295
 
285
296
  await generateConfigFiles(appPath, appName, config, existingEnv);
286
297
 
298
+ // Generate external system files if type is external
299
+ if (config.type === 'external') {
300
+ const externalGenerator = require('./external-system-generator');
301
+ await externalGenerator.generateExternalSystemFiles(appPath, appName, config);
302
+ }
303
+
287
304
  if (options.app) {
288
305
  await setupAppFiles(appName, appPath, config, options);
289
306
  }
@@ -106,9 +106,14 @@ function createAuditEntry(level, message, metadata = {}) {
106
106
  for (const [key, value] of Object.entries(metadata)) {
107
107
  // Skip null and undefined values to keep logs clean
108
108
  if (value !== null && value !== undefined) {
109
- entry.metadata[key] = maskSensitiveData(
110
- typeof value === 'string' ? value : JSON.stringify(value)
111
- );
109
+ // Only mask strings; preserve other types (numbers, booleans, etc.)
110
+ if (typeof value === 'string') {
111
+ entry.metadata[key] = maskSensitiveData(value);
112
+ } else {
113
+ // For non-string values, stringify only if it's an object/array
114
+ // Preserve primitives (numbers, booleans) as-is
115
+ entry.metadata[key] = typeof value === 'object' ? JSON.stringify(value) : value;
116
+ }
112
117
  }
113
118
  }
114
119
 
@@ -280,7 +285,7 @@ async function logApiCall(url, options, statusCode, duration, success, errorInfo
280
285
  path,
281
286
  url: maskSensitiveData(url),
282
287
  controllerUrl: maskSensitiveData(controllerUrl),
283
- statusCode,
288
+ statusCode: Number(statusCode),
284
289
  duration,
285
290
  success,
286
291
  timestamp: Date.now()
package/lib/build.js CHANGED
@@ -335,6 +335,14 @@ async function postBuildTasks(appName, buildConfig) {
335
335
  * // Returns: 'myapp:latest'
336
336
  */
337
337
  async function buildApp(appName, options = {}) {
338
+ // Check if app type is external - deploy to dataplane instead of Docker build
339
+ const variables = await loadVariablesYaml(appName);
340
+ if (variables.app && variables.app.type === 'external') {
341
+ const externalDeploy = require('./external-system-deploy');
342
+ await externalDeploy.buildExternalSystem(appName, options);
343
+ return null;
344
+ }
345
+
338
346
  try {
339
347
  logger.log(chalk.blue(`\n🔨 Building application: ${appName}`));
340
348
 
package/lib/cli.js CHANGED
@@ -38,6 +38,8 @@ function setupCommands(program) {
38
38
  .option('--client-id <id>', 'Client ID (for credentials method, overrides secrets.local.yaml)')
39
39
  .option('--client-secret <secret>', 'Client Secret (for credentials method, overrides secrets.local.yaml)')
40
40
  .option('-e, --environment <env>', 'Environment key (updates root-level environment in config.yaml, e.g., miso, dev, tst, pro)')
41
+ .option('--offline', 'Request offline token (adds offline_access scope, device flow only)')
42
+ .option('--scope <scopes>', 'Custom OAuth2 scope string (device flow only, default: "openid profile email")')
41
43
  .action(async(options) => {
42
44
  try {
43
45
  await handleLogin(options);
@@ -103,12 +105,18 @@ function setupCommands(program) {
103
105
  .option('-a, --authentication', 'Requires authentication/RBAC')
104
106
  .option('-l, --language <lang>', 'Runtime language (typescript/python)')
105
107
  .option('-t, --template <name>', 'Template to use (e.g., miso-controller, keycloak)')
108
+ .option('--type <type>', 'Application type (webapp, api, service, functionapp, external)', 'webapp')
106
109
  .option('--app', 'Generate minimal application files (package.json, index.ts or requirements.txt, main.py)')
107
110
  .option('-g, --github', 'Generate GitHub Actions workflows')
108
111
  .option('--github-steps <steps>', 'Extra GitHub workflow steps (comma-separated, e.g., npm,test)')
109
112
  .option('--main-branch <branch>', 'Main branch name for workflows', 'main')
110
113
  .action(async(appName, options) => {
111
114
  try {
115
+ // Validate type if provided
116
+ const validTypes = ['webapp', 'api', 'service', 'functionapp', 'external'];
117
+ if (options.type && !validTypes.includes(options.type)) {
118
+ throw new Error(`Invalid type: ${options.type}. Must be one of: ${validTypes.join(', ')}`);
119
+ }
112
120
  await app.createApp(appName, options);
113
121
  } catch (error) {
114
122
  handleCommandError(error, 'create');
@@ -158,6 +166,46 @@ function setupCommands(program) {
158
166
  }
159
167
  });
160
168
 
169
+ // Environment deployment command
170
+ const environment = program
171
+ .command('environment')
172
+ .description('Manage environments');
173
+
174
+ const deployEnvHandler = async(envKey, options) => {
175
+ try {
176
+ const environmentDeploy = require('./environment-deploy');
177
+ await environmentDeploy.deployEnvironment(envKey, options);
178
+ } catch (error) {
179
+ handleCommandError(error, 'environment deploy');
180
+ process.exit(1);
181
+ }
182
+ };
183
+
184
+ environment
185
+ .command('deploy <env>')
186
+ .description('Deploy/setup environment in Miso Controller')
187
+ .option('-c, --controller <url>', 'Controller URL (required)')
188
+ .option('--config <file>', 'Environment configuration file')
189
+ .option('--skip-validation', 'Skip environment validation')
190
+ .option('--poll', 'Poll for deployment status', true)
191
+ .option('--no-poll', 'Do not poll for status')
192
+ .action(deployEnvHandler);
193
+
194
+ // Alias: env deploy (register as separate command since Commander.js doesn't support multi-word aliases)
195
+ const env = program
196
+ .command('env')
197
+ .description('Environment management (alias for environment)');
198
+
199
+ env
200
+ .command('deploy <env>')
201
+ .description('Deploy/setup environment in Miso Controller')
202
+ .option('-c, --controller <url>', 'Controller URL (required)')
203
+ .option('--config <file>', 'Environment configuration file')
204
+ .option('--skip-validation', 'Skip environment validation')
205
+ .option('--poll', 'Poll for deployment status', true)
206
+ .option('--no-poll', 'Do not poll for status')
207
+ .action(deployEnvHandler);
208
+
161
209
  program.command('deploy <app>')
162
210
  .description('Deploy to Azure via Miso Controller')
163
211
  .option('-c, --controller <url>', 'Controller URL')
@@ -302,20 +302,51 @@ async function pollAndSaveDeviceCodeToken(controllerUrl, deviceCode, interval, e
302
302
  }
303
303
  }
304
304
 
305
+ /**
306
+ * Build scope string from options
307
+ * @param {boolean} [offline] - Whether to request offline_access
308
+ * @param {string} [customScope] - Custom scope string
309
+ * @returns {string} Scope string
310
+ */
311
+ function buildScope(offline, customScope) {
312
+ const defaultScope = 'openid profile email';
313
+
314
+ if (customScope) {
315
+ // If custom scope provided, use it and optionally add offline_access
316
+ if (offline && !customScope.includes('offline_access')) {
317
+ return `${customScope} offline_access`;
318
+ }
319
+ return customScope;
320
+ }
321
+
322
+ // Default scope with optional offline_access
323
+ if (offline) {
324
+ return `${defaultScope} offline_access`;
325
+ }
326
+
327
+ return defaultScope;
328
+ }
329
+
305
330
  /**
306
331
  * Handle device code flow login
307
332
  * @async
308
333
  * @param {string} controllerUrl - Controller URL
309
334
  * @param {string} [environment] - Environment key from options
335
+ * @param {boolean} [offline] - Whether to request offline_access scope
336
+ * @param {string} [scope] - Custom scope string
310
337
  * @returns {Promise<{token: string, environment: string}>} Token and environment
311
338
  */
312
- async function handleDeviceCodeLogin(controllerUrl, environment) {
339
+ async function handleDeviceCodeLogin(controllerUrl, environment, offline, scope) {
313
340
  const envKey = await getEnvironmentKey(environment);
341
+ const requestScope = buildScope(offline, scope);
314
342
 
315
343
  logger.log(chalk.blue('\n📱 Initiating device code flow...\n'));
344
+ if (offline) {
345
+ logger.log(chalk.gray(`Requesting offline token (scope: ${requestScope})\n`));
346
+ }
316
347
 
317
348
  try {
318
- const deviceCodeResponse = await initiateDeviceCodeFlow(controllerUrl, envKey);
349
+ const deviceCodeResponse = await initiateDeviceCodeFlow(controllerUrl, envKey, requestScope);
319
350
 
320
351
  displayDeviceCodeInfo(deviceCodeResponse.user_code, deviceCodeResponse.verification_uri, logger, chalk);
321
352
 
@@ -369,6 +400,12 @@ async function handleLogin(options) {
369
400
  let token;
370
401
  let expiresAt;
371
402
 
403
+ // Validate scope options - only applicable to device flow
404
+ if (method === 'credentials' && (options.offline || options.scope)) {
405
+ logger.log(chalk.yellow('⚠️ Warning: --offline and --scope options are only available for device flow'));
406
+ logger.log(chalk.gray(' These options will be ignored for credentials method\n'));
407
+ }
408
+
372
409
  if (method === 'credentials') {
373
410
  if (!options.app) {
374
411
  logger.error(chalk.red('❌ --app is required for credentials login method'));
@@ -379,7 +416,7 @@ async function handleLogin(options) {
379
416
  expiresAt = loginResult.expiresAt;
380
417
  await saveCredentialsLoginConfig(controllerUrl, token, expiresAt, environment, options.app);
381
418
  } else if (method === 'device') {
382
- const result = await handleDeviceCodeLogin(controllerUrl, options.environment);
419
+ const result = await handleDeviceCodeLogin(controllerUrl, options.environment, options.offline, options.scope);
383
420
  token = result.token;
384
421
  environment = result.environment;
385
422
  return; // Early return for device flow (already saved config)