diff --git a/README.md b/README.md index 5472d1cb..76d7925f 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,42 @@ dotnet tool install -g Microsoft.Agents.A365.DevTools.Cli ### 2. Configure -You can configure the CLI in two ways: - -#### a) Interactive setup +Configure the CLI using the interactive wizard: ```bash a365 config init ``` -This will prompt you for all required Azure and agent details, then write `a365.config.json` to the correct location. -The interactive setup includes helpful prompts with: -- **Smart defaults** based on your current context -- **Format validation** for fields like User Principal Names -- **Path verification** to ensure your project directory exists -- **Clear examples** and guidance for each field +The wizard provides: +- **Azure CLI integration** - Automatically detects your Azure subscription, tenant, and resources +- **Smart defaults** - Uses values from existing configuration or generates sensible defaults +- **Minimal input** - Only requires 2-3 core values (agent name, deployment path, manager email) +- **Auto-generation** - Creates related resource names from your agent name +- **Platform detection** - Validates your project type (.NET, Node.js, or Python) + +**What you'll be prompted for:** +- **Agent name** - A unique identifier for your agent (alphanumeric only) +- **Deployment project path** - Path to your agent project directory +- **Manager email** - Email of the manager overseeing this agent +- **Azure resources** - Select from existing resource groups and app service plans + +The wizard will automatically generate: +- Web app names +- Agent identity names +- User principal names +- Display names + +**Import from file:** +```bash +a365 config init -c path/to/config.json +``` -**Minimum required properties:** +**Global configuration:** +```bash +a365 config init --global +``` + +**Minimum required configuration:** ```json { "tenantId": "your-tenant-id", @@ -48,15 +68,7 @@ The interactive setup includes helpful prompts with: } ``` -**Required Fields Explained:** - -- **`agentUserPrincipalName`**: The User Principal Name (UPN) for the agentic user in email format (e.g., `demo.agent@contoso.onmicrosoft.com`). This creates a dedicated user identity for your agent within Microsoft 365. -- **`agentUserDisplayName`**: Human-readable display name shown in Microsoft 365 applications (e.g., "Sales Assistant Agent" or "Demo Agent User"). -- **`deploymentProjectPath`**: Path to your agent project directory containing the application files. Supports both relative paths (e.g., `./src`) and absolute paths. - -**Note:** The CLI automatically detects your project type (.NET, Node.js, or Python) and builds accordingly. No need to specify project files manually. - -See `a365.config.example.json` for all available options and required properties. +See `a365.config.example.json` for all available options. ### 3. Setup (Blueprint + Messaging Endpoint) diff --git a/docs/commands/config-init.md b/docs/commands/config-init.md index 2abe1ca2..e886e8cb 100644 --- a/docs/commands/config-init.md +++ b/docs/commands/config-init.md @@ -1,162 +1,409 @@ # Agent 365 CLI - Configuration Initialization Guide > **Command**: `a365 config init` -> **Purpose**: Initialize your Agent 365 configuration with all required settings for deployment +> **Purpose**: Interactive wizard to configure Agent 365 with Azure CLI integration and smart defaults ## Overview -The `a365 config init` command walks you through creating a complete configuration file (`a365.config.json`) for your Agent 365 deployment. This interactive process collects essential information about your Azure subscription, agent identity, and deployment settings. +The `a365 config init` command provides an intelligent, interactive configuration wizard that minimizes manual input by leveraging Azure CLI integration and smart defaults. The wizard automatically detects your Azure subscription, suggests resource names, and validates your inputs to ensure a smooth setup experience. ## Quick Start ```bash -# Initialize configuration with interactive prompts +# Initialize configuration with interactive wizard a365 config init -# Use existing config as starting point -a365 config init --config path/to/existing/a365.config.json +# Import existing config file +a365 config init --configfile path/to/existing/a365.config.json + +# Create config in global directory (AppData) +a365 config init --global ``` -## Configuration Fields +## Key Features -### Azure Infrastructure +- **Azure CLI Integration**: Automatically detects your Azure subscription, tenant, and available resources +- **Smart Defaults**: Generates sensible defaults for resource names, agent identities, and UPNs +- **Resource Discovery**: Lists existing resource groups, app service plans, and locations +- **Platform Detection**: Automatically detects project type (.NET, Node.js, Python) +- **Input Validation**: Validates paths, UPNs, emails, and Azure resources +- **Interactive Prompts**: Press Enter to accept defaults or type to customize -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **tenantId** | Azure AD Tenant ID | `12345678-1234-...` | ? Yes | -| **subscriptionId** | Azure Subscription ID | `87654321-4321-...` | ? Yes | -| **resourceGroup** | Azure Resource Group name | `my-agent-rg` | ? Yes | -| **location** | Azure region | `eastus`, `westus2` | ? Yes | -| **appServicePlanName** | App Service Plan name | `my-agent-plan` | ? Yes | -| **appServicePlanSku** | Service Plan SKU | `B1`, `S1`, `P1V2` | ? No (defaults to `B1`) | -| **webAppName** | Web App name (must be globally unique) | `my-agent-webapp` | ? Yes | +## Configuration Flow -### Agent Identity +### Step 1: Azure CLI Verification -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **agentIdentityDisplayName** | Name shown in Azure AD for the agent identity | `My Agent Identity` | ? Yes | -| **agentBlueprintDisplayName** | Name for the agent blueprint | `My Agent Blueprint` | ? Yes | -| **agentUserPrincipalName** | UPN for the agentic user | `demo.agent@contoso.onmicrosoft.com` | ? Yes | -| **agentUserDisplayName** | Display name for the agentic user | `Demo Agent` | ? Yes | -| **agentDescription** | Description of your agent | `My helpful support agent` | ? No | -| **managerEmail** | Email of the agent's manager | `manager@contoso.com` | ? No | -| **agentUserUsageLocation** | Country code for license assignment | `US`, `GB`, `DE` | ? No (defaults to `US`) | +The wizard first verifies your Azure CLI authentication: -### Deployment Settings +``` +Checking Azure CLI authentication... +Subscription ID: e09e22f2-9193-4f54-a335-01f59575eefd (My Subscription) +Tenant ID: adfa4542-3e1e-46f5-9c70-3df0b15b3f6c -| Field | Description | Example | Required | -|-------|-------------|---------|----------| -| **deploymentProjectPath** | Path to agent project directory | `C:\projects\my-agent` or `./my-agent` | ? Yes | +NOTE: Defaulted from current Azure account. To use a different Azure subscription, +run 'az login' and then 'az account set --subscription ' before +running this command. +``` -## Interactive Prompts +**If not logged in:** +``` +ERROR: You are not logged in to Azure CLI. +Please run 'az login' and then try again. +``` -When you run `a365 config init`, you'll see detailed prompts for each field: +### Step 2: Agent Name -### Example: Agent User Principal Name +Provide a unique name for your agent. This is used to generate derived names for resources: ``` ----------------------------------------------- - Agent User Principal Name (UPN) ----------------------------------------------- -Description : Email-like identifier for the agentic user in Azure AD. - Format: @.onmicrosoft.com or @ - Example: demo.agent@contoso.onmicrosoft.com - This must be unique in your tenant. +Agent name [agent1114]: myagent +``` + +**Smart Defaults**: If no existing config, defaults to `agent` + current date (e.g., `agent1114`) + +### Step 3: Deployment Project Path + +Specify the path to your agent project: + +``` +Deployment project path [C:\A365-Ignite-Demo\sample_agent]: +Detected DotNet project +``` + +**Features**: +- Defaults to current directory or existing config path +- Validates directory exists +- Detects project platform (.NET, Node.js, Python) +- Warns if no supported project type detected + +### Step 4: Resource Group Selection -Current Value: [agent.john@yourdomain.onmicrosoft.com] +Choose from existing resource groups or create a new one: -> demo.agent@contoso.onmicrosoft.com ``` +Available resource groups: + 1. a365demorg + 2. another-rg + 3. -### Example: Deployment Project Path +Select resource group (1-3) [1]: 1 +``` + +**Smart Behavior**: +- Lists existing resource groups from your subscription +- Option to create new resource group +- Defaults to existing config value if available + +### Step 5: App Service Plan Selection + +Choose from existing app service plans in the selected resource group: ``` ----------------------------------------------- - Deployment Project Path ----------------------------------------------- -Description : Path to your agent project directory for deployment. - This should contain your agent's source code and configuration files. - The directory must exist and be accessible. - You can use relative paths (e.g., ./my-agent) or absolute paths. +Available app service plans in resource group 'a365demorg': + 1. a365agent-app-plan + 2. + +Select app service plan (1-2) [1]: 1 +``` + +**Smart Behavior**: +- Only shows plans in the selected resource group +- Option to create new plan +- Defaults to existing config value + +### Step 6: Manager Email -Current Value: [C:\Users\john\projects\current-directory] +Provide the email address of the agent's manager: -> ./my-agent +``` +Manager email [agent365demo.manager1@a365preview001.onmicrosoft.com]: ``` -## Field Validation +**Validation**: Ensures valid email format -The CLI validates your input to catch errors early: +### Step 7: Azure Location -### Agent User Principal Name (UPN) +Choose the Azure region for deployment: -? **Valid formats**: -- `demo.agent@contoso.onmicrosoft.com` -- `support-bot@verified-domain.com` +``` +Azure location [eastus]: +``` -? **Invalid formats**: -- `invalidupn` (missing @) -- `user@` (missing domain) -- `@domain.com` (missing username) +**Smart Defaults**: Uses location from existing config or Azure account -### Deployment Project Path +### Step 8: Configuration Summary -? **Valid paths**: -- `./my-agent` (relative path) -- `C:\projects\my-agent` (absolute path) -- `../parent-folder/my-agent` (parent directory) +Review all settings before saving: -? **Invalid paths**: -- `Z:\nonexistent\path` (directory doesn't exist) -- `C:\|invalid` (illegal characters) +``` +================================================================= + Configuration Summary +================================================================= +Agent Name : myagent +Web App Name : myagent-webapp-11140916 +Agent Identity Name : myagent Identity +Agent Blueprint Name : myagent Blueprint +Agent UPN : agent.myagent.11140916@yourdomain.onmicrosoft.com +Agent Display Name : myagent Agent User +Manager Email : agent365demo.manager1@a365preview001.onmicrosoft.com +Deployment Path : C:\A365-Ignite-Demo\sample_agent +Resource Group : a365demorg +App Service Plan : a365agent-app-plan +Location : eastus +Subscription : My Subscription (e09e22f2-9193-4f54-a335-01f59575eefd) +Tenant : adfa4542-3e1e-46f5-9c70-3df0b15b3f6c + +Do you want to customize any derived names? (y/N): +``` -### Empty Values +### Step 9: Name Customization (Optional) -All required fields must have values: +Optionally customize generated names: ``` -? This field is required. Please provide a value. +Do you want to customize any derived names? (y/N): y + +Web App Name [myagent-webapp-11140916]: myagent-prod +Agent Identity Display Name [myagent Identity]: +Agent Blueprint Display Name [myagent Blueprint]: +Agent User Principal Name [agent.myagent.11140916@yourdomain.onmicrosoft.com]: +Agent User Display Name [myagent Agent User]: +``` + +### Step 10: Confirmation + +Final confirmation to save: + +``` +Save this configuration? (Y/n): Y + +Configuration saved to: C:\Users\user\a365.config.json + +You can now run: + a365 setup - Create Azure resources + a365 deploy - Deploy your agent +``` + +## Configuration Fields + +The wizard automatically populates these fields: + +### Azure Infrastructure (Auto-detected from Azure CLI) + +| Field | Description | Source | Example | +|-------|-------------|--------|---------| +| **tenantId** | Azure AD Tenant ID | Azure CLI (`az account show`) | `adfa4542-3e1e-46f5-9c70-3df0b15b3f6c` | +| **subscriptionId** | Azure Subscription ID | Azure CLI (`az account show`) | `e09e22f2-9193-4f54-a335-01f59575eefd` | +| **resourceGroup** | Azure Resource Group name | User selection from list | `a365demorg` | +| **location** | Azure region | Azure account or user input | `eastus` | +| **appServicePlanName** | App Service Plan name | User selection from list | `a365agent-app-plan` | +| **appServicePlanSku** | Service Plan SKU | Default value | `B1` | + +### Agent Identity (Auto-generated with customization option) + +| Field | Description | Generation Logic | Example | +|-------|-------------|------------------|---------| +| **webAppName** | Web App name (globally unique) | `{agentName}-webapp-{timestamp}` | `myagent-webapp-11140916` | +| **agentIdentityDisplayName** | Agent identity in Azure AD | `{agentName} Identity` | `myagent Identity` | +| **agentBlueprintDisplayName** | Agent blueprint name | `{agentName} Blueprint` | `myagent Blueprint` | +| **agentUserPrincipalName** | UPN for the agentic user | `agent.{agentName}.{timestamp}@domain` | `agent.myagent.11140916@yourdomain.onmicrosoft.com` | +| **agentUserDisplayName** | Display name for agentic user | `{agentName} Agent User` | `myagent Agent User` | +| **agentDescription** | Description of your agent | `{agentName} - Agent 365 Demo Agent` | `myagent - Agent 365 Demo Agent` | + +### User-Provided Fields + +| Field | Description | Validation | Example | +|-------|-------------|------------|---------| +| **managerEmail** | Email of the agent's manager | Email format | `manager@contoso.com` | +| **deploymentProjectPath** | Path to agent project directory | Directory exists, platform detection | `C:\projects\my-agent` | +| **agentUserUsageLocation** | Country code for license | Auto-detected from Azure account | `US` | + +## Command Options + +```bash +# Display help +a365 config init --help + +# Import existing configuration file +a365 config init --configfile path/to/config.json +a365 config init -c path/to/config.json + +# Create config in global directory (AppData) +a365 config init --global +a365 config init -g ``` ## Generated Configuration File -After completing the prompts, `a365 config init` creates `a365.config.json`: +After completing the wizard, `a365.config.json` is created: ```json { - "tenantId": "12345678-1234-1234-1234-123456789012", - "subscriptionId": "87654321-4321-4321-4321-210987654321", - "resourceGroup": "my-agent-rg", + "tenantId": "adfa4542-3e1e-46f5-9c70-3df0b15b3f6c", + "subscriptionId": "e09e22f2-9193-4f54-a335-01f59575eefd", + "resourceGroup": "a365demorg", "location": "eastus", - "appServicePlanName": "my-agent-plan", + "environment": "prod", + "appServicePlanName": "a365agent-app-plan", "appServicePlanSku": "B1", - "webAppName": "my-agent-webapp", - "agentIdentityDisplayName": "My Agent Identity", - "agentBlueprintDisplayName": "My Agent Blueprint", - "agentUserPrincipalName": "demo.agent@contoso.onmicrosoft.com", - "agentUserDisplayName": "Demo Agent", - "deploymentProjectPath": "C:\\projects\\my-agent", - "agentDescription": "My helpful support agent", + "webAppName": "myagent-webapp-11140916", + "agentIdentityDisplayName": "myagent Identity", + "agentBlueprintDisplayName": "myagent Blueprint", + "agentUserPrincipalName": "agent.myagent.11140916@yourdomain.onmicrosoft.com", + "agentUserDisplayName": "myagent Agent User", "managerEmail": "manager@contoso.com", - "agentUserUsageLocation": "US" + "agentUserUsageLocation": "US", + "deploymentProjectPath": "C:\\projects\\my-agent", + "agentDescription": "myagent - Agent 365 Demo Agent" } ``` -## Smart Defaults +## Smart Default Generation -The CLI provides intelligent defaults based on your environment: +The wizard uses intelligent algorithms to generate defaults: -| Field | Default Value | Logic | -|-------|---------------|-------| -| **agentIdentityDisplayName** | `John's Agent 365 Instance 20241112T153045` | `'s Agent 365 Instance ` | -| **agentBlueprintDisplayName** | `John's Agent 365 Blueprint` | `'s Agent 365 Blueprint` | -| **agentUserPrincipalName** | `agent.john@yourdomain.onmicrosoft.com` | `agent.@yourdomain.onmicrosoft.com` | -| **agentUserDisplayName** | `John's Agent User` | `'s Agent User` | -| **deploymentProjectPath** | `C:\projects\current-directory` | Current working directory | -| **agentUserUsageLocation** | `US` | United States | +### Agent Name Derivation -## Usage with Other Commands +**Input**: `myagent` + +**Generated Names**: +``` +webAppName = myagent-webapp-11140916 +agentIdentityDisplayName = myagent Identity +agentBlueprintDisplayName = myagent Blueprint +agentUserPrincipalName = agent.myagent.11140916@yourdomain.onmicrosoft.com +agentUserDisplayName = myagent Agent User +agentDescription = myagent - Agent 365 Demo Agent +``` + +**Timestamp**: `MMddHHmm` format (e.g., `11140916` = Nov 14, 09:16 AM) + +### Usage Location Detection + +Automatically determined from Azure account home tenant location: +- US-based tenants → `US` +- UK-based tenants → `GB` +- Canada-based tenants → `CA` +- Falls back to `US` if unable to detect + +## Validation Rules + +### Deployment Project Path + +- **Existence**: Directory must exist on the file system +- **Platform Detection**: Warns if no supported project type (.NET, Node.js, Python) is detected +- **Confirmation**: User can choose to continue even without detected platform + +``` +WARNING: Could not detect a supported project type (.NET, Node.js, or Python) +in the specified directory. +Continue anyway? (y/N): +``` + +### Resource Group + +- **Existence**: Must select from existing resource groups or create new +- **Format**: Azure naming conventions (lowercase, alphanumeric, hyphens) + +### App Service Plan + +- **Scope**: Must exist in the selected resource group +- **Fallback**: Option to create new plan if none exist + +### Manager Email + +- **Format**: Valid email address (contains `@` and domain) + +- **Format**: Valid email address (contains `@` and domain) + +## Azure CLI Integration + +The wizard leverages Azure CLI for automatic resource discovery: + +### Prerequisites + +```bash +# Install Azure CLI (if not already installed) +# Windows: https://learn.microsoft.com/cli/azure/install-azure-cli-windows +# macOS: brew install azure-cli +# Linux: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + +# Login to Azure +az login + +# Set active subscription (if you have multiple) +az account set --subscription "My Subscription" + +# Verify current account +az account show +``` + +### What the Wizard Fetches + +1. **Current Azure Account**: + - Subscription ID and Name + - Tenant ID + - User information + - Home tenant location (for usage location) + +2. **Resource Groups**: + - Lists all resource groups in your subscription + - Allows selection or creation of new group + +3. **App Service Plans**: + - Lists plans in the selected resource group + - Filters by location compatibility + - Shows SKU and pricing tier + +4. **Azure Locations**: + - Lists available Azure regions + - Suggests location based on account or existing config + +### Error Handling + +**Not logged in**: +``` +ERROR: You are not logged in to Azure CLI. +Please run 'az login' and then try again. +``` + +**Solution**: Run `az login` and complete browser authentication + +**Multiple subscriptions**: +``` +Subscription ID: e09e22f2-9193-4f54-a335-01f59575eefd (Subscription 1) + +NOTE: To use a different Azure subscription, run 'az login' and then +'az account set --subscription ' before running this command. +``` + +**Solution**: Set desired subscription with `az account set` + +## Updating Existing Configuration + +Re-run the wizard to update your configuration: + +```bash +# Wizard will load existing values as defaults +a365 config init + +# Or import from a different file +a365 config init --configfile production.config.json +``` + +**Workflow**: +1. Wizard detects existing `a365.config.json` +2. Displays message: "Found existing configuration. Default values will be used where available." +3. Each prompt shows current value in brackets: `[current-value]` +4. Press **Enter** to keep current value +5. Type new value to update + +**Example**: +``` +Agent name [myagent]: myagent-v2 +Deployment project path [C:\projects\my-agent]: ← Press Enter to keep +Resource group [a365demorg]: new-rg ← Type to update +``` ### Setup Command @@ -315,7 +562,8 @@ After running `a365 config init`: 1. **Review the generated config**: ```bash - cat a365.config.json + # View static configuration + a365 config display ``` 2. **Run setup** to create Azure resources: @@ -323,19 +571,16 @@ After running `a365 config init`: a365 setup ``` -3. **Create agent instance**: - ```bash - a365 create-instance - ``` - -4. **Deploy your agent**: +3. **Deploy your agent**: ```bash a365 deploy ``` -## Support +## Additional Resources -For issues or questions: +- **Command Reference**: [a365 config display](config-display.md) +- **Setup Guide**: [a365 setup](setup.md) +- **Deployment Guide**: [a365 deploy](deploy.md) - **GitHub Issues**: [Agent 365 Repository](https://github.com/microsoft/Agent365-devTools/issues) - **Documentation**: [Microsoft Learn](https://learn.microsoft.com/agent365) -- **Community**: [Microsoft Tech Community](https://techcommunity.microsoft.com) + diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index e455739c..3a8298cf 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -95,10 +95,19 @@ Microsoft.Agents.A365.DevTools.Cli/ The CLI provides a `config` command for managing configuration: -- `a365 config init` — Interactively prompts for required config values and writes `a365.config.json`. -- `a365 config init -c ` — Imports and validates a config file, then writes it to the standard location. +- `a365 config init` — Interactive wizard with Azure CLI integration and smart defaults. Prompts for agent name, deployment path, and manager email. Auto-generates resource names and validates configuration. +- `a365 config init -c ` — Imports and validates a config file from the specified path. +- `a365 config init --global` — Creates configuration in global directory (AppData) instead of current directory. - `a365 config display` — Prints the current configuration. +**Configuration Wizard Features:** +- **Azure CLI Integration**: Automatically detects subscription, tenant, resource groups, and app service plans +- **Smart Defaults**: Uses existing configuration values or generates intelligent defaults +- **Minimal Input**: Only requires 2-3 core fields (agent name, deployment path, manager email) +- **Auto-Generation**: Creates webapp names, identity names, and UPNs from the agent name +- **Platform Detection**: Validates project type (.NET, Node.js, Python) in deployment path +- **Dual Save**: Saves to both local project directory and global cache for reuse + ### MCP Server Management Command The CLI provides a `develop-mcp` command for managing Model Context Protocol (MCP) servers in Dataverse environments. The command follows a **minimal configuration approach** - it defaults to the production environment and only requires additional configuration when needed. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs index 0163175c..e5b6748c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs @@ -6,25 +6,33 @@ using Microsoft.Extensions.Logging; using System.Runtime.InteropServices; using System.Globalization; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; namespace Microsoft.Agents.A365.DevTools.Cli.Commands; public static class ConfigCommand { - public static Command CreateCommand(ILogger logger, string? configDir = null) + public static Command CreateCommand(ILogger logger, string? configDir = null, IConfigurationWizardService? wizardService = null) { var directory = configDir ?? Services.ConfigService.GetGlobalConfigDirectory(); var command = new Command("config", "Configure Azure subscription, resource settings, and deployment options\nfor a365 CLI commands"); - command.AddCommand(CreateInitSubcommand(logger, directory)); + + if (wizardService != null) + { + command.AddCommand(CreateInitSubcommand(logger, directory, wizardService)); + } + command.AddCommand(CreateDisplaySubcommand(logger, directory)); + return command; } - private static Command CreateInitSubcommand(ILogger logger, string configDir) + private static Command CreateInitSubcommand(ILogger logger, string configDir, IConfigurationWizardService wizardService) { - var cmd = new Command("init", "Initialize configuration settings for Azure resources, agent identity,\nand deployment options used by subsequent Agent 365 commands") + var cmd = new Command("init", "Interactive wizard to configure Agent 365 with Azure CLI integration and smart defaults") { - new Option(new[] { "-c", "--configfile" }, "Path to a config file to import"), + new Option(new[] { "-c", "--configfile" }, "Path to an existing config file to import"), new Option(new[] { "--global", "-g" }, "Create config in global directory (AppData) instead of current directory") }; @@ -36,24 +44,17 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir) string? configFile = context.ParseResult.GetValueForOption(configFileOption); bool useGlobal = context.ParseResult.GetValueForOption(globalOption); - // Create local config by default, unless --global flag is used + // Determine config path string configPath = useGlobal ? Path.Combine(configDir, "a365.config.json") : Path.Combine(Environment.CurrentDirectory, "a365.config.json"); - if (!useGlobal) - { - logger.LogInformation("Initializing local configuration..."); - } - else + if (useGlobal) { Directory.CreateDirectory(configDir); - logger.LogInformation("Initializing global configuration..."); } - var configModelType = typeof(Models.Agent365Config); - Models.Agent365Config config; - + // If config file is specified, import it directly if (!string.IsNullOrEmpty(configFile)) { if (!File.Exists(configFile)) @@ -61,324 +62,104 @@ private static Command CreateInitSubcommand(ILogger logger, string configDir) logger.LogError($"Config file '{configFile}' not found."); return; } - var json = await File.ReadAllTextAsync(configFile); - try - { - config = JsonSerializer.Deserialize(json) ?? new Models.Agent365Config(); - } - catch (Exception ex) - { - logger.LogError($"Failed to parse config file: {ex.Message}"); - return; - } - } - else - { - // Check for existing configuration to use as defaults - Models.Agent365Config? existingConfig = null; - var localConfigPath = Path.Combine(Environment.CurrentDirectory, "a365.config.json"); - var globalConfigPath = Path.Combine(configDir, "a365.config.json"); - bool hasExistingConfig = false; - // Try to load existing config (local first, then global) - if (File.Exists(localConfigPath)) - { - try - { - var existingJson = await File.ReadAllTextAsync(localConfigPath); - existingConfig = JsonSerializer.Deserialize(existingJson); - hasExistingConfig = true; - } - catch (Exception ex) - { - logger.LogWarning($"Could not parse existing local config: {ex.Message}"); - } - } - else if (File.Exists(globalConfigPath)) + try { - try - { - var existingJson = await File.ReadAllTextAsync(globalConfigPath); - existingConfig = JsonSerializer.Deserialize(existingJson); - hasExistingConfig = true; - } - catch (Exception ex) + var json = await File.ReadAllTextAsync(configFile); + var importedConfig = JsonSerializer.Deserialize(json); + + if (importedConfig == null) { - logger.LogWarning($"Could not parse existing global config: {ex.Message}"); + logger.LogError("Failed to parse config file."); + return; } - } - string PromptWithHelp(string prompt, string help, string? defaultValue = null, Func? validator = null) - { - // Validate default value and fix if needed - if (defaultValue != null && validator != null) + // Validate imported config + var errors = importedConfig.Validate(); + if (errors.Count > 0) { - var (isValidDefault, _) = validator(defaultValue); - if (!isValidDefault) + logger.LogError("Imported configuration is invalid:"); + foreach (var err in errors) { - defaultValue = null; // Clear invalid default, force user to enter valid value + logger.LogError($" {err}"); } + return; } + + // Save to target location + var outputJson = JsonSerializer.Serialize(importedConfig, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(configPath, outputJson); - // Section divider - Console.WriteLine("----------------------------------------------"); - Console.WriteLine($" {prompt}"); - Console.WriteLine("----------------------------------------------"); - - // Multi-line description - Console.WriteLine($"Description : {help}"); - Console.WriteLine(); - - // Current value display - if (defaultValue != null) + // Also save to global if saving locally + if (!useGlobal) { - Console.WriteLine($"Current Value: [{defaultValue}]"); + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + Directory.CreateDirectory(configDir); + await File.WriteAllTextAsync(globalConfigPath, outputJson); } - Console.WriteLine(); - string input; - do - { - Console.Write("> "); - input = Console.ReadLine()?.Trim() ?? ""; - - if (string.IsNullOrWhiteSpace(input) && defaultValue != null) - { - input = defaultValue; - } - - if (string.IsNullOrWhiteSpace(input)) - { - Console.WriteLine("This field is required. Please provide a value."); - Console.Write("> "); - continue; - } - - if (validator != null) - { - var (isValid, error) = validator(input); - if (!isValid) - { - Console.WriteLine(error); - Console.Write("> "); - continue; - } - } - - break; - } while (true); - - return input; + logger.LogInformation($"\nConfiguration imported to: {configPath}"); + return; + } + catch (Exception ex) + { + logger.LogError($"Failed to import config file: {ex.Message}"); + return; } + } - // Generate sensible defaults based on user environment or existing config - var userName = Environment.UserName.ToLowerInvariant(); - var timestamp = DateTime.Now.ToString("MMdd"); - - Console.WriteLine(); - Console.WriteLine("----------------------------------------------"); - Console.WriteLine(" Agent 365 CLI - Configuration Setup"); - Console.WriteLine("----------------------------------------------"); - Console.WriteLine(); - - if (hasExistingConfig) + // Load existing config if it exists + Agent365Config? existingConfig = null; + if (File.Exists(configPath)) + { + try { - Console.WriteLine("A configuration file already exists in this directory."); - Console.WriteLine("Press **Enter** to keep a current value, or type a new one to update it."); + var existingJson = await File.ReadAllTextAsync(configPath); + existingConfig = JsonSerializer.Deserialize(existingJson); + logger.LogDebug($"Loaded existing configuration from: {configPath}"); } - else + catch (Exception ex) { - Console.WriteLine("Setting up your Agent 365 CLI configuration."); - Console.WriteLine("Please provide the required configuration details below."); + logger.LogWarning($"Could not load existing config from {configPath}: {ex.Message}"); } - Console.WriteLine(); + } - config = new Models.Agent365Config + try + { + // Run the wizard with existing config + var config = await wizardService.RunWizardAsync(existingConfig); + + if (config != null) { - TenantId = PromptWithHelp( - "Azure Tenant ID", - "Your Azure Active Directory tenant identifier (GUID format).\n You can find this in the Azure Portal under:\n Azure Active Directory > Overview > Tenant ID", - existingConfig?.TenantId, - input => Guid.TryParse(input, out _) ? (true, "") : (false, "Must be a valid GUID format (e.g., 12345678-1234-1234-1234-123456789abc)") - ), - - SubscriptionId = PromptWithHelp( - "Azure Subscription ID", - "The Azure subscription where resources will be created.\n You can find this in the Azure Portal under:\n Subscriptions > [Your Subscription] > Overview > Subscription ID", - existingConfig?.SubscriptionId, - input => Guid.TryParse(input, out _) ? (true, "") : (false, "Must be a valid GUID format") - ), + // Save the configuration + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - ResourceGroup = PromptWithHelp( - "Resource Group Name", - "Azure resource group name for organizing related resources.\n Must be 1-90 characters, alphanumeric, periods, underscores, hyphens and parenthesis.", - existingConfig?.ResourceGroup ?? $"{userName}-agent365-rg" - ), + // Save to primary location (local or global based on flag) + await File.WriteAllTextAsync(configPath, json); - Location = PromptWithHelp( - "Azure Location", - "Azure region where resources will be deployed.\n Common options: eastus, westus2, centralus, westeurope, eastasia\n You can find all regions in the Azure Portal under:\n Create a resource > [Any service] > Basics > Region dropdown", - existingConfig?.Location ?? "eastus", - input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Location cannot be empty") - ), - - AppServicePlanName = PromptWithHelp( - "App Service Plan Name", - "Name for the Azure App Service Plan that will host your agent web app.\n This defines the compute resources (CPU, memory) for your application.\n A new plan will be created if it doesn't exist.", - existingConfig?.AppServicePlanName ?? $"{userName}-agent365-plan" - ), - - WebAppName = PromptWithHelp( - "Web App Name", - "Globally unique name for your Azure Web App.\n This will be part of your agent's URL: https://.azurewebsites.net\n Must be unique across all Azure Web Apps worldwide.\n Only alphanumeric characters and hyphens allowed (no underscores).\n Cannot start or end with a hyphen. Maximum 60 characters.", - existingConfig?.WebAppName ?? $"{userName}-agent365-{timestamp}", - input => { - // Azure Web App naming rules: - // - 2-60 characters - // - Only alphanumeric and hyphens (NO underscores) - // - Cannot start or end with hyphen - // - Must be globally unique - - if (input.Length < 2 || input.Length > 60) - return (false, "Must be between 2-60 characters"); - - if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$")) - return (false, "Only alphanumeric characters and hyphens allowed (no underscores). Cannot start or end with a hyphen."); - - if (input.Contains("_")) - return (false, "Underscores are not allowed in Azure Web App names. Use hyphens (-) instead."); - - return (true, ""); - } - ), - - AgentIdentityDisplayName = PromptWithHelp( - "Agent Identity Display Name", - "Human-readable name for your agent identity.\n This will appear in Azure Active Directory and admin interfaces.\n Use a descriptive name to easily identify this agent.", - existingConfig?.AgentIdentityDisplayName ?? $"{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(userName)}'s Agent 365 Instance {timestamp}" - ), - - AgentUserPrincipalName = PromptWithHelp( - "Agent User Principal Name (UPN)", - "Email-like identifier for the agentic user in Azure AD.\n Format: @.onmicrosoft.com or @\n Example: demo.agent@contoso.onmicrosoft.com\n This must be unique in your tenant.", - existingConfig?.AgentUserPrincipalName ?? $"agent.{userName}@yourdomain.onmicrosoft.com", - input => { - // Basic email format validation - if (!input.Contains("@") || !input.Contains(".")) - return (false, "Must be a valid email-like format (e.g., user@domain.onmicrosoft.com)"); - - var parts = input.Split('@'); - if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) - return (false, "Invalid UPN format. Use: username@domain"); - - return (true, ""); - } - ), - - AgentUserDisplayName = PromptWithHelp( - "Agent User Display Name", - "Human-readable name for the agentic user.\n This will appear in Teams, Outlook, and other Microsoft 365 apps.\n Example: 'Demo Agent' or 'Support Bot'", - existingConfig?.AgentUserDisplayName ?? $"{CultureInfo.CurrentCulture.TextInfo.ToTitleCase(userName)}'s Agent User" - ), + // Also save to global config directory for reuse + if (!useGlobal) + { + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + Directory.CreateDirectory(configDir); + await File.WriteAllTextAsync(globalConfigPath, json); + } - DeploymentProjectPath = PromptWithHelp( - "Deployment Project Path", - "Path to your agent project directory for deployment.\n This should contain your agent's source code and configuration files.\n The directory must exist and be accessible.\n You can use relative paths (e.g., ./my-agent) or absolute paths.", - existingConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, - input => { - try - { - var fullPath = Path.GetFullPath(input); - if (!Directory.Exists(fullPath)) - return (false, $"Directory does not exist: {fullPath}"); - return (true, ""); - } - catch (Exception ex) - { - return (false, $"Invalid path: {ex.Message}"); - } - } - ) - // AgentIdentityScopes and AgentApplicationScopes are read-only properties that return hardcoded defaults - }; - - Console.WriteLine(); - Console.WriteLine("Configuration setup completed successfully!"); - } - - // Validate config - var errors = config.Validate(); - if (errors.Count > 0) - { - logger.LogError("Configuration is invalid:"); - Console.WriteLine("Configuration is invalid:"); - foreach (var err in errors) - { - logger.LogError(" " + err); - Console.WriteLine(" " + err); + logger.LogInformation($"\nConfiguration saved to: {configPath}"); + logger.LogInformation("\nYou can now run:"); + logger.LogInformation(" a365 setup - Create Azure resources"); + logger.LogInformation(" a365 deploy - Deploy your agent"); } - logger.LogError("Aborted. Please fix the above errors and try again."); - Console.WriteLine("Aborted. Please fix the above errors and try again."); - return; - } - - // Re-validate before writing as a defensive check - var finalErrors = config.Validate(); - if (finalErrors.Count > 0) - { - logger.LogError("Configuration validation failed before writing. Aborting write."); - return; - } - - if (File.Exists(configPath)) - { - Console.Write($"Config file already exists at {configPath}. Overwrite? (y/N): "); - var answer = Console.ReadLine(); - if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase)) + else { - logger.LogInformation("Aborted by user. Config not overwritten."); - return; + // Wizard returned null - could be user cancellation or error + // Error details already logged by the wizard service + logger.LogDebug("Configuration wizard returned null"); } } - - // Serialize only static properties (init-only) to a365.config.json - var staticConfig = new - { - tenantId = config.TenantId, - subscriptionId = config.SubscriptionId, - resourceGroup = config.ResourceGroup, - location = config.Location, - appServicePlanName = config.AppServicePlanName, - appServicePlanSku = config.AppServicePlanSku, - webAppName = config.WebAppName, - agentIdentityDisplayName = config.AgentIdentityDisplayName, - agentBlueprintDisplayName = config.AgentBlueprintDisplayName, - agentUserPrincipalName = config.AgentUserPrincipalName, - agentUserDisplayName = config.AgentUserDisplayName, - managerEmail = config.ManagerEmail, - agentUserUsageLocation = config.AgentUserUsageLocation, - // agentIdentityScopes and agentApplicationScopes are hardcoded - not persisted to config file - deploymentProjectPath = config.DeploymentProjectPath, - agentDescription = config.AgentDescription, - // enableTeamsChannel, enableEmailChannel, enableGraphApiRegistration are hardcoded - not persisted to config file - mcpDefaultServers = config.McpDefaultServers - }; - - var options = new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - var configJson = JsonSerializer.Serialize(staticConfig, options); - await File.WriteAllTextAsync(configPath, configJson); - logger.LogInformation($"Config written to {configPath}"); - - // If imported from file, display the config - if (!string.IsNullOrEmpty(configFile)) + catch (Exception ex) { - var displayCmd = CreateDisplaySubcommand(logger, configDir); - await displayCmd.InvokeAsync(""); + logger.LogError(ex, "Failed to complete configuration: {Message}", ex.Message); } }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index a9392cfa..b9b44b51 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -178,6 +178,7 @@ static JsonObject RequireObj(JsonObject parent, string prop) if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) { svcSettings["ClientId"] = pkgConfig.AgentBlueprintId; + svcSettings["AgentId"] = pkgConfig.AgentBlueprintId; } svcSettings["Scopes"] = new JsonArray(DEFAULT_SERVICE_CONNECTION_SCOPE); @@ -212,11 +213,14 @@ void Set(string key, string? value) var safe = $"{key}={EscapeEnv(value)}"; if (idx >= 0) lines[idx] = safe; else lines.Add(safe); + } + + // --- Service Connection --- + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + { + Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", pkgConfig.AgentBlueprintId); + Set("AGENT_ID", pkgConfig.AgentBlueprintId); } - - // --- Service Connection --- - if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) - Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", pkgConfig.AgentBlueprintId); if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) Set("CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", pkgConfig.AgentBlueprintClientSecret); if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) @@ -256,8 +260,11 @@ void Set(string key, string? value) } // --- Service Connection --- - if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) - Set("connections__service_connection__settings__clientId", pkgConfig.AgentBlueprintId); + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + { + Set("connections__service_connection__settings__clientId", pkgConfig.AgentBlueprintId); + Set("agent_id", pkgConfig.AgentBlueprintId); + } if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) Set("connections__service_connection__settings__clientSecret", pkgConfig.AgentBlueprintClientSecret); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs new file mode 100644 index 00000000..64297c95 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AzureModels.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Azure account information from Azure CLI +/// +public class AzureAccountInfo +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public AzureUser User { get; set; } = new(); + public string State { get; set; } = string.Empty; + public bool IsDefault { get; set; } +} + +/// +/// Azure user information +/// +public class AzureUser +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; +} + +/// +/// Azure resource group information +/// +public class AzureResourceGroup +{ + public string Name { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; +} + +/// +/// Azure app service plan information +/// +public class AzureAppServicePlan +{ + public string Name { get; set; } = string.Empty; + public string ResourceGroup { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string Sku { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; +} + +/// +/// Azure location information +/// +public class AzureLocation +{ + public string Name { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string RegionalDisplayName { get; set; } = string.Empty; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs new file mode 100644 index 00000000..b5d75322 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ConfigDerivedNames.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Contains derived names generated from an agent name during configuration initialization +/// +public class ConfigDerivedNames +{ + public string WebAppName { get; set; } = string.Empty; + public string AgentIdentityDisplayName { get; set; } = string.Empty; + public string AgentBlueprintDisplayName { get; set; } = string.Empty; + public string AgentUserPrincipalName { get; set; } = string.Empty; + public string AgentUserDisplayName { get; set; } = string.Empty; +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 6e98879d..405564b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -90,7 +90,8 @@ static async Task Main(string[] args) // Register ConfigCommand var configLoggerFactory = serviceProvider.GetRequiredService(); var configLogger = configLoggerFactory.CreateLogger("ConfigCommand"); - rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger)); + var wizardService = serviceProvider.GetRequiredService(); + rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService)); rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, executor)); rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, graphApiService)); @@ -219,6 +220,10 @@ private static void ConfigureServices(IServiceCollection services) // Register AzureWebAppCreator for SDK-based web app creation services.AddSingleton(); + + // Register Azure CLI service and Configuration Wizard + services.AddSingleton(); + services.AddSingleton(); } public static string GetDisplayVersion() diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index 3c3a5629..47c6ce19 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -846,6 +846,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, /// /// Create Federated Identity Credential to link managed identity to blueprint /// Equivalent to createFederatedIdentityCredential function in PowerShell + /// Implements retry logic to handle Azure AD propagation delays /// private async Task CreateFederatedIdentityCredentialAsync( string tenantId, @@ -854,6 +855,9 @@ private async Task CreateFederatedIdentityCredentialAsync( string msiPrincipalId, CancellationToken ct) { + const int maxRetries = 5; + const int initialDelayMs = 2000; // Start with 2 seconds + try { var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct); @@ -875,23 +879,44 @@ private async Task CreateFederatedIdentityCredentialAsync( httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); var url = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/federatedIdentityCredentials"; - var response = await httpClient.PostAsync( - url, - new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - if (!response.IsSuccessStatusCode) + // Retry loop to handle propagation delays + for (int attempt = 1; attempt <= maxRetries; attempt++) { + var response = await httpClient.PostAsync( + url, + new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), + ct); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation(" - Credential Name: {Name}", credentialName); + _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); + _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); + return true; + } + var error = await response.Content.ReadAsStringAsync(ct); + + // Check if it's a propagation issue (resource not found) + if (error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) + { + if (attempt < maxRetries) + { + var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1); // Exponential backoff + _logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...", + attempt, maxRetries, delayMs); + await Task.Delay(delayMs, ct); + continue; + } + } + + // Other error or max retries reached _logger.LogError("Failed to create federated identity credential: {Error}", error); return false; } - _logger.LogInformation(" - Credential Name: {Name}", credentialName); - _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId); - _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId); - - return true; + return false; } catch (Exception ex) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs new file mode 100644 index 00000000..fa5e4737 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureCliService.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +public class AzureCliService : IAzureCliService +{ + private readonly CommandExecutor _commandExecutor; + private readonly ILogger _logger; + + public AzureCliService(CommandExecutor commandExecutor, ILogger logger) + { + _commandExecutor = commandExecutor; + _logger = logger; + } + + public async Task IsLoggedInAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account show", + suppressErrorLogging: true + ); + return result.Success; + } + catch (Exception ex) + { + _logger.LogDebug("Error checking Azure CLI login status: {Error}", ex.Message); + return false; + } + } + + public async Task GetCurrentAccountAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account show --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to get Azure account information. Ensure you are logged in with 'az login'"); + return null; + } + + var accountJson = JsonSerializer.Deserialize(result.StandardOutput); + + return new AzureAccountInfo + { + Id = accountJson.GetProperty("id").GetString() ?? string.Empty, + Name = accountJson.GetProperty("name").GetString() ?? string.Empty, + TenantId = accountJson.GetProperty("tenantId").GetString() ?? string.Empty, + User = new AzureUser + { + Name = accountJson.GetProperty("user").GetProperty("name").GetString() ?? string.Empty, + Type = accountJson.GetProperty("user").GetProperty("type").GetString() ?? string.Empty + }, + State = accountJson.GetProperty("state").GetString() ?? string.Empty, + IsDefault = accountJson.GetProperty("isDefault").GetBoolean() + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Azure account information"); + return null; + } + } + + public async Task> ListResourceGroupsAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "group list --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list resource groups"); + return new List(); + } + + var resourceGroupsJson = JsonSerializer.Deserialize(result.StandardOutput); + + return resourceGroupsJson?.Select(rg => new AzureResourceGroup + { + Name = rg.GetProperty("name").GetString() ?? string.Empty, + Location = rg.GetProperty("location").GetString() ?? string.Empty, + Id = rg.GetProperty("id").GetString() ?? string.Empty + }).OrderBy(rg => rg.Name).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing resource groups"); + return new List(); + } + } + + public async Task> ListAppServicePlansAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "appservice plan list --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list app service plans"); + return new List(); + } + + var plansJson = JsonSerializer.Deserialize(result.StandardOutput); + + return plansJson?.Select(plan => new AzureAppServicePlan + { + Name = plan.GetProperty("name").GetString() ?? string.Empty, + ResourceGroup = plan.GetProperty("resourceGroup").GetString() ?? string.Empty, + Location = plan.GetProperty("location").GetString() ?? string.Empty, + Sku = plan.GetProperty("sku").GetProperty("name").GetString() ?? string.Empty, + Id = plan.GetProperty("id").GetString() ?? string.Empty + }).OrderBy(plan => plan.Name).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing app service plans"); + return new List(); + } + } + + public async Task> ListLocationsAsync() + { + try + { + var result = await _commandExecutor.ExecuteAsync( + "az", + "account list-locations --output json" + ); + + if (!result.Success) + { + _logger.LogError("Failed to list Azure locations"); + return new List(); + } + + var locationsJson = JsonSerializer.Deserialize(result.StandardOutput); + + return locationsJson?.Select(loc => new AzureLocation + { + Name = loc.GetProperty("name").GetString() ?? string.Empty, + DisplayName = loc.GetProperty("displayName").GetString() ?? string.Empty, + RegionalDisplayName = loc.TryGetProperty("regionalDisplayName", out var regional) + ? regional.GetString() ?? string.Empty + : string.Empty + }).OrderBy(loc => loc.DisplayName).ToList() ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing Azure locations"); + return new List(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs new file mode 100644 index 00000000..a93c8940 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for simplifying Agent 365 configuration initialization with smart defaults and Azure CLI integration +/// +public interface IConfigurationWizardService +{ + /// + /// Runs an interactive configuration wizard that minimizes user input by leveraging Azure CLI and smart defaults + /// + /// Existing configuration to use for defaults, if any + /// Configured Agent365Config instance + Task RunWizardAsync(Agent365Config? existingConfig = null); +} + +public class ConfigurationWizardService : IConfigurationWizardService +{ + private readonly IAzureCliService _azureCliService; + private readonly PlatformDetector _platformDetector; + private readonly ILogger _logger; + + public ConfigurationWizardService( + IAzureCliService azureCliService, + PlatformDetector platformDetector, + ILogger logger) + { + _azureCliService = azureCliService; + _platformDetector = platformDetector; + _logger = logger; + } + + public async Task RunWizardAsync(Agent365Config? existingConfig = null) + { + try + { + if (existingConfig != null) + { + _logger.LogDebug("Using existing configuration with deploymentProjectPath: {Path}", existingConfig.DeploymentProjectPath ?? "(null)"); + Console.WriteLine("Found existing configuration. Default values will be used where available."); + Console.WriteLine("Press Enter to keep a current value, or type a new one to update it."); + Console.WriteLine(); + } + + // Step 1: Verify Azure CLI login + if (!await VerifyAzureLoginAsync()) + { + _logger.LogError("Configuration wizard cancelled: Azure CLI authentication required"); + return null; + } + + // Step 2: Get Azure account info + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + if (accountInfo == null) + { + _logger.LogError("Failed to retrieve Azure account information. Please run 'az login' first"); + return null; + } + + Console.WriteLine($"Subscription ID: {accountInfo.Id} ({accountInfo.Name})"); + Console.WriteLine($"Tenant ID: {accountInfo.TenantId}"); + Console.WriteLine(); + Console.WriteLine("NOTE: Defaulted from current Azure account. To use a different Azure subscription, run 'az login' and then 'az account set --subscription ' before running this command."); + Console.WriteLine(); + + // Step 3: Get unique agent name + var agentName = PromptForAgentName(existingConfig); + if (string.IsNullOrWhiteSpace(agentName)) + { + _logger.LogError("Agent name is required. Configuration cancelled"); + return null; + } + + var derivedNames = GenerateDerivedNames(agentName); + + // Step 4: Validate deployment project path + var deploymentPath = await PromptForDeploymentPathAsync(existingConfig); + if (string.IsNullOrWhiteSpace(deploymentPath)) + { + _logger.LogError("Configuration wizard cancelled: Deployment project path not provided or invalid"); + return null; + } + + // Step 5: Select Resource Group + var resourceGroup = await PromptForResourceGroupAsync(existingConfig); + if (string.IsNullOrWhiteSpace(resourceGroup)) + { + _logger.LogError("Configuration wizard cancelled: Resource group not selected"); + return null; + } + + // Step 6: Select App Service Plan + var appServicePlan = await PromptForAppServicePlanAsync(existingConfig, resourceGroup); + if (string.IsNullOrWhiteSpace(appServicePlan)) + { + _logger.LogError("Configuration wizard cancelled: App Service Plan not selected"); + return null; + } + + // Step 7: Get manager email (required for agent creation) + var managerEmail = PromptForManagerEmail(existingConfig); + if (string.IsNullOrWhiteSpace(managerEmail)) + { + _logger.LogError("Configuration wizard cancelled: Manager email not provided"); + return null; + } + + // Step 8: Get location (with smart default from account or existing config) + var location = await PromptForLocationAsync(existingConfig, accountInfo); + + // Step 9: Show configuration summary and allow override + Console.WriteLine(); + Console.WriteLine("================================================================="); + Console.WriteLine(" Configuration Summary"); + Console.WriteLine("================================================================="); + Console.WriteLine($"Agent Name : {agentName}"); + Console.WriteLine($"Web App Name : {derivedNames.WebAppName}"); + Console.WriteLine($"Agent Identity Name : {derivedNames.AgentIdentityDisplayName}"); + Console.WriteLine($"Agent Blueprint Name : {derivedNames.AgentBlueprintDisplayName}"); + Console.WriteLine($"Agent UPN : {derivedNames.AgentUserPrincipalName}"); + Console.WriteLine($"Agent Display Name : {derivedNames.AgentUserDisplayName}"); + Console.WriteLine($"Manager Email : {managerEmail}"); + Console.WriteLine($"Deployment Path : {deploymentPath}"); + Console.WriteLine($"Resource Group : {resourceGroup}"); + Console.WriteLine($"App Service Plan : {appServicePlan}"); + Console.WriteLine($"Location : {location}"); + Console.WriteLine($"Subscription : {accountInfo.Name} ({accountInfo.Id})"); + Console.WriteLine($"Tenant : {accountInfo.TenantId}"); + Console.WriteLine(); + + // Step 10: Allow customization of derived names + var customizedNames = PromptForNameCustomization(derivedNames); + + // Step 11: Final confirmation to save configuration + Console.Write("Save this configuration? (Y/n): "); + var saveResponse = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (saveResponse == "n" || saveResponse == "no") + { + Console.WriteLine("Configuration cancelled."); + _logger.LogInformation("Configuration wizard cancelled by user"); + return null; + } + + // Step 12: Build final configuration + var config = new Agent365Config + { + TenantId = accountInfo.TenantId, + SubscriptionId = accountInfo.Id, + ResourceGroup = resourceGroup, + Location = location, + Environment = existingConfig?.Environment ?? "prod", // Default to prod, not asking for this + AppServicePlanName = appServicePlan, + AppServicePlanSku = existingConfig?.AppServicePlanSku ?? "B1", // Default to B1, not asking + WebAppName = customizedNames.WebAppName, + AgentIdentityDisplayName = customizedNames.AgentIdentityDisplayName, + AgentBlueprintDisplayName = customizedNames.AgentBlueprintDisplayName, + AgentUserPrincipalName = customizedNames.AgentUserPrincipalName, + AgentUserDisplayName = customizedNames.AgentUserDisplayName, + ManagerEmail = managerEmail, + AgentUserUsageLocation = GetUsageLocationFromAccount(accountInfo), + DeploymentProjectPath = deploymentPath, + AgentDescription = $"{agentName} - Agent 365 Agent" + }; + + _logger.LogInformation("Configuration wizard completed successfully"); + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Configuration wizard failed: {Message}", ex.Message); + return null; + } + } + + private async Task VerifyAzureLoginAsync() + { + if (!await _azureCliService.IsLoggedInAsync()) + { + _logger.LogError("You are not logged in to Azure CLI. Please run 'az login' and select your subscription, then try again"); + return false; + } + + return true; + } + + private string PromptForAgentName(Agent365Config? existingConfig) + { + string defaultName; + if (existingConfig != null) + { + defaultName = ExtractAgentNameFromConfig(existingConfig); + } + else + { + // Generate alphanumeric-only default + var username = System.Text.RegularExpressions.Regex.Replace(Environment.UserName, @"[^a-zA-Z0-9]", ""); + defaultName = $"{username}agent{DateTime.Now:MMdd}"; + } + + return PromptWithDefault( + "Agent name", + defaultName, + ValidateAgentName + ); + } + + private string ExtractAgentNameFromConfig(Agent365Config config) + { + // Try to extract a reasonable agent name from existing config + if (!string.IsNullOrEmpty(config.WebAppName)) + { + // Remove common suffixes and clean up + var name = config.WebAppName; + name = System.Text.RegularExpressions.Regex.Replace(name, @"(webapp|app|web|agent|bot)$", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + name = System.Text.RegularExpressions.Regex.Replace(name, @"[-_]", ""); // Remove all hyphens and underscores + name = System.Text.RegularExpressions.Regex.Replace(name, @"[^a-zA-Z0-9]", ""); // Remove any remaining non-alphanumeric + if (!string.IsNullOrWhiteSpace(name) && name.Length > 2 && char.IsLetter(name[0])) + { + return name; + } + } + + return $"agent{DateTime.Now:MMdd}"; + } + + private async Task PromptForDeploymentPathAsync(Agent365Config? existingConfig) + { + var defaultPath = existingConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory; + + await Task.CompletedTask; // Satisfy async requirement + var path = PromptWithDefault( + "Deployment project path", + defaultPath, + ValidateDeploymentPath + ); + + // Additional validation using PlatformDetector + if (!string.IsNullOrWhiteSpace(path)) + { + var platform = _platformDetector.Detect(path); + if (platform == ProjectPlatform.Unknown) + { + Console.WriteLine("WARNING: Could not detect a supported project type (.NET, Node.js, or Python) in the specified directory."); + Console.Write("Continue anyway? (y/N): "); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (response != "y" && response != "yes") + { + _logger.LogError("Deployment path must contain a valid project. Configuration cancelled"); + return string.Empty; + } + } + else + { + Console.WriteLine($"Detected {platform} project"); + } + } + + return path; + } + + private async Task PromptForResourceGroupAsync(Agent365Config? existingConfig) + { + Console.WriteLine(); + Console.WriteLine("Loading resource groups from Azure..."); + + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + if (!resourceGroups.Any()) + { + Console.WriteLine("WARNING: No resource groups found. You may need to create one first."); + return PromptWithDefault( + "Resource group name", + existingConfig?.ResourceGroup ?? $"{Environment.UserName}-agent365-rg", + input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Resource group name cannot be empty") + ); + } + + Console.WriteLine(); + Console.WriteLine("Available Resource Groups:"); + for (int i = 0; i < resourceGroups.Count; i++) + { + Console.WriteLine($"{i + 1:D2}. {resourceGroups[i].Name} ({resourceGroups[i].Location})"); + } + Console.WriteLine(); + + var defaultIndex = existingConfig?.ResourceGroup != null ? + resourceGroups.FindIndex(rg => rg.Name.Equals(existingConfig.ResourceGroup, StringComparison.OrdinalIgnoreCase)) + 1 : + 1; + + while (true) + { + Console.Write($"Select resource group [1-{resourceGroups.Count}] (default: {Math.Max(1, defaultIndex)}): "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + input = Math.Max(1, defaultIndex).ToString(); + } + + if (int.TryParse(input, out int index) && index >= 1 && index <= resourceGroups.Count) + { + return resourceGroups[index - 1].Name; + } + + Console.WriteLine($"Please enter a number between 1 and {resourceGroups.Count}"); + } + } + + private async Task PromptForAppServicePlanAsync(Agent365Config? existingConfig, string resourceGroup) + { + Console.WriteLine(); + Console.WriteLine("Loading app service plans from Azure..."); + + var allPlans = await _azureCliService.ListAppServicePlansAsync(); + var plansInRg = allPlans.Where(p => p.ResourceGroup.Equals(resourceGroup, StringComparison.OrdinalIgnoreCase)).ToList(); + + Console.WriteLine(); + if (plansInRg.Any()) + { + Console.WriteLine($"App Service Plans in {resourceGroup}:"); + for (int i = 0; i < plansInRg.Count; i++) + { + Console.WriteLine($"{i + 1:D2}. {plansInRg[i].Name} ({plansInRg[i].Sku}, {plansInRg[i].Location})"); + } + Console.WriteLine($"{plansInRg.Count + 1:D2}. Create new app service plan"); + Console.WriteLine(); + + var defaultIndex = existingConfig?.AppServicePlanName != null ? + plansInRg.FindIndex(p => p.Name.Equals(existingConfig.AppServicePlanName, StringComparison.OrdinalIgnoreCase)) + 1 : + plansInRg.Count + 1; // Default to creating new + + while (true) + { + Console.Write($"Select option [1-{plansInRg.Count + 1}] (default: {Math.Max(1, defaultIndex)}): "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + input = Math.Max(1, defaultIndex).ToString(); + } + + if (int.TryParse(input, out int index)) + { + if (index >= 1 && index <= plansInRg.Count) + { + return plansInRg[index - 1].Name; + } + else if (index == plansInRg.Count + 1) + { + // Create new plan name + return $"{Environment.UserName}-agent365-plan"; + } + } + + Console.WriteLine($"Please enter a number between 1 and {plansInRg.Count + 1}"); + } + } + else + { + Console.WriteLine($"No existing app service plans found in {resourceGroup}. A new plan will be created."); + return existingConfig?.AppServicePlanName ?? $"{Environment.UserName}-agent365-plan"; + } + } + + private string PromptForManagerEmail(Agent365Config? existingConfig) + { + return PromptWithDefault( + "Manager email", + existingConfig?.ManagerEmail ?? "", + ValidateEmail + ); + } + + private async Task PromptForLocationAsync(Agent365Config? existingConfig, AzureAccountInfo accountInfo) + { + // Try to get a smart default location + var defaultLocation = existingConfig?.Location; + + if (string.IsNullOrEmpty(defaultLocation)) + { + // Try to get from resource group or common defaults + defaultLocation = "eastus"; // Conservative default + } + + await Task.CompletedTask; // Satisfy async requirement + return PromptWithDefault( + "Azure location", + defaultLocation, + input => !string.IsNullOrWhiteSpace(input) ? (true, "") : (false, "Location cannot be empty") + ); + } + + private ConfigDerivedNames GenerateDerivedNames(string agentName) + { + var cleanName = System.Text.RegularExpressions.Regex.Replace(agentName, @"[^a-zA-Z0-9]", "").ToLowerInvariant(); + var timestamp = DateTime.Now.ToString("MMddHHmm"); + + return new ConfigDerivedNames + { + WebAppName = $"{cleanName}-webapp-{timestamp}", + AgentIdentityDisplayName = $"{agentName} Identity", + AgentBlueprintDisplayName = $"{agentName} Blueprint", + AgentUserPrincipalName = $"agent.{cleanName}.{timestamp}@yourdomain.onmicrosoft.com", + AgentUserDisplayName = $"{agentName} Agent User" + }; + } + + private ConfigDerivedNames PromptForNameCustomization(ConfigDerivedNames defaultNames) + { + Console.Write("Would you like to customize the generated names? (y/N): "); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + + if (response != "y" && response != "yes") + { + return defaultNames; + } + + Console.WriteLine(); + Console.WriteLine("Customizing generated names (press Enter to keep default):"); + + return new ConfigDerivedNames + { + WebAppName = PromptWithDefault("Web app name", defaultNames.WebAppName, ValidateWebAppName), + AgentIdentityDisplayName = PromptWithDefault("Agent identity name", defaultNames.AgentIdentityDisplayName), + AgentBlueprintDisplayName = PromptWithDefault("Agent blueprint name", defaultNames.AgentBlueprintDisplayName), + AgentUserPrincipalName = PromptWithDefault("Agent UPN", defaultNames.AgentUserPrincipalName, ValidateEmail), + AgentUserDisplayName = PromptWithDefault("Agent display name", defaultNames.AgentUserDisplayName) + }; + } + + private string PromptWithDefault( + string prompt, + string defaultValue = "", + Func? validator = null) + { + // Azure CLI style: "Prompt [default]: " + while (true) + { + if (!string.IsNullOrEmpty(defaultValue)) + { + Console.Write($"{prompt} [{defaultValue}]: "); + } + else + { + Console.Write($"{prompt}: "); + } + + var input = Console.ReadLine()?.Trim() ?? ""; + + if (string.IsNullOrWhiteSpace(input) && !string.IsNullOrEmpty(defaultValue)) + { + input = defaultValue; + } + + if (string.IsNullOrWhiteSpace(input)) + { + Console.WriteLine("ERROR: This field is required."); + continue; + } + + if (validator != null) + { + var (isValid, error) = validator(input); + if (!isValid) + { + Console.WriteLine($"ERROR: {error}"); + continue; + } + } + + return input; + } + } + + private static (bool isValid, string error) ValidateAgentName(string input) + { + if (input.Length < 2 || input.Length > 50) + return (false, "Agent name must be between 2-50 characters"); + + if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z][a-zA-Z0-9]*$")) + return (false, "Agent name must start with a letter and contain only letters and numbers (no special characters for cross-platform compatibility)"); + + return (true, ""); + } + + private (bool isValid, string error) ValidateDeploymentPath(string input) + { + try + { + var fullPath = Path.GetFullPath(input); + if (!Directory.Exists(fullPath)) + return (false, $"Directory does not exist: {fullPath}"); + return (true, ""); + } + catch (Exception ex) + { + return (false, $"Invalid path: {ex.Message}"); + } + } + + private static (bool isValid, string error) ValidateWebAppName(string input) + { + if (input.Length < 2 || input.Length > 60) + return (false, "Must be between 2-60 characters"); + + if (!System.Text.RegularExpressions.Regex.IsMatch(input, @"^[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9]$")) + return (false, "Only alphanumeric characters and hyphens allowed. Cannot start or end with a hyphen."); + + if (input.Contains("_")) + return (false, "Underscores are not allowed in Azure Web App names. Use hyphens (-) instead."); + + return (true, ""); + } + + private static (bool isValid, string error) ValidateEmail(string input) + { + if (!input.Contains("@") || !input.Contains(".")) + return (false, "Must be a valid email format"); + + var parts = input.Split('@'); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) + return (false, "Invalid email format. Use: username@domain"); + + return (true, ""); + } + + private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) + { + // Default to US for now - could be enhanced to detect from account location + return "US"; + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs new file mode 100644 index 00000000..2b1e140e --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAzureCliService.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for interacting with Azure CLI to fetch account information, resource groups, and other Azure data +/// +public interface IAzureCliService +{ + /// + /// Gets the current Azure account information from Azure CLI + /// + /// Current Azure account info or null if not logged in + Task GetCurrentAccountAsync(); + + /// + /// Lists all resource groups in the current subscription + /// + /// List of resource groups + Task> ListResourceGroupsAsync(); + + /// + /// Lists all app service plans in the current subscription + /// + /// List of app service plans + Task> ListAppServicePlansAsync(); + + /// + /// Lists all available Azure locations + /// + /// List of available locations + Task> ListLocationsAsync(); + + /// + /// Checks if Azure CLI is available and user is logged in + /// + /// True if Azure CLI is available and logged in + Task IsLoggedInAsync(); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs index 226aef60..76cc9e51 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs @@ -1,487 +1,545 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.CommandLine; -using System.IO; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.Agents.A365.DevTools.Cli.Commands; -using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; - -/// -/// Tests for ConfigCommand. -/// These tests are run sequentially (not in parallel) because they interact with shared global state -/// in %LocalAppData% (Windows) or ~/.config (Linux/Mac). -/// -[Collection("ConfigTests")] -public class ConfigCommandTests -{ - // Use NullLoggerFactory instead of console logger to avoid I/O bottleneck during test runs - private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; - - private string GetTestConfigDir() - { - var dir = Path.Combine(Path.GetTempPath(), "a365_cli_tests", Guid.NewGuid().ToString()); - return dir; - } - - - - [Fact(Skip = "Disabled due to System.CommandLine invocation overhead when running full test suite")] - public async Task Init_ValidConfigFile_IsAcceptedAndSaved() - { - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - var configPath = Path.Combine(configDir, "a365.config.json"); - - var validConfig = new Agent365Config - { - TenantId = "12345678-1234-1234-1234-123456789012", - SubscriptionId = "87654321-4321-4321-4321-210987654321", - ResourceGroup = "rg-test", - Location = "eastus", - AppServicePlanName = "asp-test", - WebAppName = "webapp-test", - AgentIdentityDisplayName = "Test Agent" - // AgentIdentityScopes and AgentApplicationScopes are now hardcoded - }; - var importPath = Path.Combine(configDir, "import.json"); - await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(validConfig)); - - var originalOut = Console.Out; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync($"config init -c \"{importPath}\""); - Assert.Equal(0, result); - Assert.True(File.Exists(configPath)); - var json = File.ReadAllText(configPath); - Assert.Contains("12345678-1234-1234-1234-123456789012", json); - } - finally - { - Console.SetOut(originalOut); - if (Directory.Exists(configDir)) Directory.Delete(configDir, true); - } - } - - [Fact] - public async Task Init_InvalidConfigFile_IsRejectedAndShowsError() - { - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - var configPath = Path.Combine(configDir, "a365.config.json"); - - // Missing required fields - var invalidConfig = new Agent365Config(); - var importPath = Path.Combine(configDir, "import_invalid.json"); - await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(invalidConfig)); - - var originalOut = Console.Out; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync($"config init -c \"{importPath}\""); - Assert.Equal(0, result); - Assert.False(File.Exists(configPath)); - var output = outputWriter.ToString(); - Assert.Contains("Configuration is invalid", output); - Assert.Contains("tenantId is required", output, StringComparison.OrdinalIgnoreCase); - } - finally - { - Console.SetOut(originalOut); - if (Directory.Exists(configDir)) Directory.Delete(configDir, true); - } - } - - [Fact] - public void GetDefaultConfigDirectory_Windows_ReturnsAppData() - { - // This test validates the Windows path is correct - // Actual path will vary by machine, so we just check structure - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Should contain LocalAppData path or fall back to current directory - Assert.True(result.Contains("Microsoft.Agents.A365.DevTools.Cli") || - result == Environment.CurrentDirectory); - } - } - - [Fact] - public void GetDefaultConfigDirectory_Linux_ReturnsXdgPath() - { - // This test validates XDG compliance on Linux - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Should be XDG_CONFIG_HOME/a365 or ~/.config/a365 or current directory - Assert.True(result.EndsWith("a365") || result == Environment.CurrentDirectory); - } - } - - [Fact] - public async Task Display_WithGeneratedFlag_ShowsGeneratedConfig() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create minimal static config (required by LoadAsync) - var staticConfigPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(staticConfigPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedConfigPath = Path.Combine(configDir, "a365.generated.config.json"); - var generatedContent = "{\"agentBlueprintId\":\"generated-123\",\"AgenticUserId\":\"user-456\",\"completed\":true}"; - await File.WriteAllTextAsync(generatedConfigPath, generatedContent); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Console.SetOut(outputWriter); - Environment.CurrentDirectory = configDir; // Set working directory to test dir - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --generated"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("generated-123", output); - Assert.Contains("user-456", output); - Assert.Contains("true", output); - } - finally - { - Console.SetOut(originalOut); - Environment.CurrentDirectory = originalDir; - - // Cleanup with retry to avoid file locking issues - await CleanupTestDirectoryAsync(configDir); - } - } - - [Fact] - public async Task Display_PrefersLocalConfigOverGlobal() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); // Global config dir - var localDir = GetTestConfigDir(); // Local config dir - Directory.CreateDirectory(configDir); - Directory.CreateDirectory(localDir); - - // Create global config - var globalConfigPath = Path.Combine(configDir, "a365.config.json"); - var globalConfig = new - { - tenantId = "11111111-1111-1111-1111-111111111111", - subscriptionId = "22222222-2222-2222-2222-222222222222", - resourceGroup = "global-rg", - location = "eastus", - appServicePlanName = "global-plan", - webAppName = "global-app", - agentIdentityDisplayName = "Global Agent" - }; - await File.WriteAllTextAsync(globalConfigPath, JsonSerializer.Serialize(globalConfig)); - - // Create local config (should take precedence) - var localConfigPath = Path.Combine(localDir, "a365.config.json"); - var localConfig = new - { - tenantId = "33333333-3333-3333-3333-333333333333", - subscriptionId = "44444444-4444-4444-4444-444444444444", - resourceGroup = "local-rg", - location = "eastus", - appServicePlanName = "local-plan", - webAppName = "local-app", - agentIdentityDisplayName = "Local Agent" - }; - await File.WriteAllTextAsync(localConfigPath, JsonSerializer.Serialize(localConfig)); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = localDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("33333333-3333-3333-3333-333333333333", output); - Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - await CleanupTestDirectoryAsync(localDir); - } - } - - [Fact] - public async Task Display_WithGeneratedFlag_ShowsOnlyGeneratedConfig() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create static config (required by LoadAsync) - var configPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); - await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-123\"}"); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = configDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --generated"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("generated-id-123", output); - Assert.DoesNotContain("12345678-1234-1234-1234-123456789012", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - } - } - - [Fact] - public async Task Display_WithAllFlag_ShowsBothConfigs() - { - // Arrange - var logger = _loggerFactory.CreateLogger("Test"); - var configDir = GetTestConfigDir(); - Directory.CreateDirectory(configDir); - - // Create static config with required fields - var configPath = Path.Combine(configDir, "a365.config.json"); - var minimalStaticConfig = new - { - tenantId = "12345678-1234-1234-1234-123456789012", - subscriptionId = "87654321-4321-4321-4321-210987654321", - resourceGroup = "test-rg", - location = "eastus", - appServicePlanName = "test-plan", - webAppName = "test-app", - agentIdentityDisplayName = "Test Agent", - deploymentProjectPath = configDir - }; - await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); - - // Create generated config - var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); - await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-456\"}"); - - var originalOut = Console.Out; - var originalDir = Environment.CurrentDirectory; - using var outputWriter = new StringWriter(); - try - { - Environment.CurrentDirectory = configDir; - Console.SetOut(outputWriter); - - // Act - var root = new RootCommand(); - root.AddCommand(ConfigCommand.CreateCommand(logger, configDir)); - var result = await root.InvokeAsync("config display --all"); - - // Assert - Assert.Equal(0, result); - var output = outputWriter.ToString(); - Assert.Contains("Static Configuration", output); - Assert.Contains("Generated Configuration", output); - Assert.Contains("12345678-1234-1234-1234-123456789012", output); - Assert.Contains("generated-id-456", output); - } - finally - { - Environment.CurrentDirectory = originalDir; - Console.SetOut(originalOut); - - // Cleanup with retry - await CleanupTestDirectoryAsync(configDir); - } - } - - /// - /// Helper method to clean up test directories with retry logic to handle file locking. - /// Prevents flaky test failures in CI pipelines. - /// - private static async Task CleanupTestDirectoryAsync(string directory) - { - if (!Directory.Exists(directory)) - return; - - const int maxRetries = 5; - const int delayMs = 200; - - for (int i = 0; i < maxRetries; i++) - { - try - { - // Force garbage collection and finalization to release file handles - if (i > 0) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - GC.Collect(); - await Task.Delay(delayMs); - } - - Directory.Delete(directory, true); - return; // Success - } - catch (IOException) when (i < maxRetries - 1) - { - // Retry on IOException (file locked) - continue; - } - catch (UnauthorizedAccessException) when (i < maxRetries - 1) - { - // Retry on access denied (file in use) - continue; - } - } - - // If still failing after retries, log but don't fail the test - // The temp directory will be cleaned up by the OS eventually - Console.WriteLine($"Warning: Could not delete test directory {directory} after {maxRetries} attempts. Directory may be cleaned up later."); - } - - [Fact] - public void GetDefaultConfigDirectory_Windows_ReturnsLocalAppData() - { - // Arrange - only run on Windows - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; // Skip on non-Windows - } - - // Act - var configDir = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - - // Assert - Assert.NotNull(configDir); - Assert.Contains("Microsoft.Agents.A365.DevTools.Cli", configDir); - } - - [Fact] - public void GetDefaultConfigDirectory_Linux_UsesXdgPath() - { - // Arrange - only run on Linux/Mac - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; // Skip on Windows - } - - // Save original environment - var originalXdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - var originalHome = Environment.GetEnvironmentVariable("HOME"); - - try - { - // Test 1: XDG_CONFIG_HOME is set - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", "/custom/config"); - var configDir1 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - Assert.Equal("/custom/config/a365", configDir1); - - // Test 2: XDG_CONFIG_HOME not set, HOME is set (default ~/.config/a365) - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", null); - Environment.SetEnvironmentVariable("HOME", "/home/testuser"); - var configDir2 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); - Assert.Equal("/home/testuser/.config/a365", configDir2); - } - finally - { - // Restore original environment - Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", originalXdg); - Environment.SetEnvironmentVariable("HOME", originalHome); - } - } -} - -/// -/// Test collection definition that disables parallel execution for config tests. -/// Config tests must run sequentially because they sync files to a shared global directory -/// (%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli on Windows or ~/.config/a365 on Linux/Mac). -/// Running in parallel would cause race conditions and file locking issues. -/// -[CollectionDefinition("ConfigTests", DisableParallelization = true)] -public class ConfigTestCollection -{ - // This class is never instantiated. It exists only to define the collection. -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for ConfigCommand. +/// These tests are run sequentially (not in parallel) because they interact with shared global state +/// in %LocalAppData% (Windows) or ~/.config (Linux/Mac). +/// +[Collection("ConfigTests")] +public class ConfigCommandTests +{ + // Use NullLoggerFactory instead of console logger to avoid I/O bottleneck during test runs + private readonly ILoggerFactory _loggerFactory = NullLoggerFactory.Instance; + private readonly IConfigurationWizardService _mockWizardService; + + public ConfigCommandTests() + { + // Create a mock wizard service that never actually runs (for import-only tests) + _mockWizardService = Substitute.For(); + } + + private string GetTestConfigDir() + { + var dir = Path.Combine(Path.GetTempPath(), "a365_cli_tests", Guid.NewGuid().ToString()); + return dir; + } + + + + [Fact(Skip = "Disabled due to System.CommandLine invocation overhead when running full test suite")] + public async Task Init_ValidConfigFile_IsAcceptedAndSaved() + { + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "a365.config.json"); + + var validConfig = new Agent365Config + { + TenantId = "12345678-1234-1234-1234-123456789012", + SubscriptionId = "87654321-4321-4321-4321-210987654321", + ResourceGroup = "rg-test", + Location = "eastus", + AppServicePlanName = "asp-test", + WebAppName = "webapp-test", + AgentIdentityDisplayName = "Test Agent" + // AgentIdentityScopes and AgentApplicationScopes are now hardcoded + }; + var importPath = Path.Combine(configDir, "import.json"); + await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(validConfig)); + + var originalOut = Console.Out; + using var outputWriter = new StringWriter(); + try + { + Console.SetOut(outputWriter); + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync($"config init -c \"{importPath}\""); + Assert.Equal(0, result); + Assert.True(File.Exists(configPath)); + var json = File.ReadAllText(configPath); + Assert.Contains("12345678-1234-1234-1234-123456789012", json); + } + finally + { + Console.SetOut(originalOut); + if (Directory.Exists(configDir)) Directory.Delete(configDir, true); + } + } + + [Fact] + public async Task Init_InvalidConfigFile_IsRejectedAndShowsError() + { + // Create a logger that captures output to a string + var logMessages = new List(); + var loggerFactory = LoggerFactory.Create(builder => + { + builder.AddProvider(new TestLoggerProvider(logMessages)); + builder.SetMinimumLevel(LogLevel.Debug); + }); + var logger = loggerFactory.CreateLogger("Test"); + + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "a365.config.json"); + + // Missing required fields + var invalidConfig = new Agent365Config(); + var importPath = Path.Combine(configDir, "import_invalid.json"); + await File.WriteAllTextAsync(importPath, JsonSerializer.Serialize(invalidConfig)); + + try + { + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync($"config init -c \"{importPath}\""); + Assert.Equal(0, result); + Assert.False(File.Exists(configPath)); + + // Check log messages instead of console output + var allLogs = string.Join("\n", logMessages); + Assert.Contains("Imported configuration is invalid", allLogs); + Assert.Contains("tenantId is required", allLogs, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(configDir)) Directory.Delete(configDir, true); + } + } + + [Fact] + public void GetDefaultConfigDirectory_Windows_ReturnsAppData() + { + // This test validates the Windows path is correct + // Actual path will vary by machine, so we just check structure + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Should contain LocalAppData path or fall back to current directory + Assert.True(result.Contains("Microsoft.Agents.A365.DevTools.Cli") || + result == Environment.CurrentDirectory); + } + } + + [Fact] + public void GetDefaultConfigDirectory_Linux_ReturnsXdgPath() + { + // This test validates XDG compliance on Linux + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var result = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Should be XDG_CONFIG_HOME/a365 or ~/.config/a365 or current directory + Assert.True(result.EndsWith("a365") || result == Environment.CurrentDirectory); + } + } + + [Fact] + public async Task Display_WithGeneratedFlag_ShowsGeneratedConfig() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create minimal static config (required by LoadAsync) + var staticConfigPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(staticConfigPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedConfigPath = Path.Combine(configDir, "a365.generated.config.json"); + var generatedContent = "{\"agentBlueprintId\":\"generated-123\",\"AgenticUserId\":\"user-456\",\"completed\":true}"; + await File.WriteAllTextAsync(generatedConfigPath, generatedContent); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Console.SetOut(outputWriter); + Environment.CurrentDirectory = configDir; // Set working directory to test dir + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --generated"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("generated-123", output); + Assert.Contains("user-456", output); + Assert.Contains("true", output); + } + finally + { + Console.SetOut(originalOut); + Environment.CurrentDirectory = originalDir; + + // Cleanup with retry to avoid file locking issues + await CleanupTestDirectoryAsync(configDir); + } + } + + [Fact] + public async Task Display_PrefersLocalConfigOverGlobal() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); // Global config dir + var localDir = GetTestConfigDir(); // Local config dir + Directory.CreateDirectory(configDir); + Directory.CreateDirectory(localDir); + + // Create global config + var globalConfigPath = Path.Combine(configDir, "a365.config.json"); + var globalConfig = new + { + tenantId = "11111111-1111-1111-1111-111111111111", + subscriptionId = "22222222-2222-2222-2222-222222222222", + resourceGroup = "global-rg", + location = "eastus", + appServicePlanName = "global-plan", + webAppName = "global-app", + agentIdentityDisplayName = "Global Agent" + }; + await File.WriteAllTextAsync(globalConfigPath, JsonSerializer.Serialize(globalConfig)); + + // Create local config (should take precedence) + var localConfigPath = Path.Combine(localDir, "a365.config.json"); + var localConfig = new + { + tenantId = "33333333-3333-3333-3333-333333333333", + subscriptionId = "44444444-4444-4444-4444-444444444444", + resourceGroup = "local-rg", + location = "eastus", + appServicePlanName = "local-plan", + webAppName = "local-app", + agentIdentityDisplayName = "Local Agent" + }; + await File.WriteAllTextAsync(localConfigPath, JsonSerializer.Serialize(localConfig)); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = localDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("33333333-3333-3333-3333-333333333333", output); + Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + await CleanupTestDirectoryAsync(localDir); + } + } + + [Fact] + public async Task Display_WithGeneratedFlag_ShowsOnlyGeneratedConfig() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create static config (required by LoadAsync) + var configPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); + await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-123\"}"); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = configDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --generated"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("generated-id-123", output); + Assert.DoesNotContain("12345678-1234-1234-1234-123456789012", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + } + } + + [Fact] + public async Task Display_WithAllFlag_ShowsBothConfigs() + { + // Arrange + var logger = _loggerFactory.CreateLogger("Test"); + var configDir = GetTestConfigDir(); + Directory.CreateDirectory(configDir); + + // Create static config with required fields + var configPath = Path.Combine(configDir, "a365.config.json"); + var minimalStaticConfig = new + { + tenantId = "12345678-1234-1234-1234-123456789012", + subscriptionId = "87654321-4321-4321-4321-210987654321", + resourceGroup = "test-rg", + location = "eastus", + appServicePlanName = "test-plan", + webAppName = "test-app", + agentIdentityDisplayName = "Test Agent", + deploymentProjectPath = configDir + }; + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(minimalStaticConfig)); + + // Create generated config + var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); + await File.WriteAllTextAsync(generatedPath, "{\"agentBlueprintId\":\"generated-id-456\"}"); + + var originalOut = Console.Out; + var originalDir = Environment.CurrentDirectory; + using var outputWriter = new StringWriter(); + try + { + Environment.CurrentDirectory = configDir; + Console.SetOut(outputWriter); + + // Act + var root = new RootCommand(); + root.AddCommand(ConfigCommand.CreateCommand(logger, configDir, _mockWizardService)); + var result = await root.InvokeAsync("config display --all"); + + // Assert + Assert.Equal(0, result); + var output = outputWriter.ToString(); + Assert.Contains("Static Configuration", output); + Assert.Contains("Generated Configuration", output); + Assert.Contains("12345678-1234-1234-1234-123456789012", output); + Assert.Contains("generated-id-456", output); + } + finally + { + Environment.CurrentDirectory = originalDir; + Console.SetOut(originalOut); + + // Cleanup with retry + await CleanupTestDirectoryAsync(configDir); + } + } + + /// + /// Helper method to clean up test directories with retry logic to handle file locking. + /// Prevents flaky test failures in CI pipelines. + /// + private static async Task CleanupTestDirectoryAsync(string directory) + { + if (!Directory.Exists(directory)) + return; + + const int maxRetries = 5; + const int delayMs = 200; + + for (int i = 0; i < maxRetries; i++) + { + try + { + // Force garbage collection and finalization to release file handles + if (i > 0) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + await Task.Delay(delayMs); + } + + Directory.Delete(directory, true); + return; // Success + } + catch (IOException) when (i < maxRetries - 1) + { + // Retry on IOException (file locked) + continue; + } + catch (UnauthorizedAccessException) when (i < maxRetries - 1) + { + // Retry on access denied (file in use) + continue; + } + } + + // If still failing after retries, log but don't fail the test + // The temp directory will be cleaned up by the OS eventually + Console.WriteLine($"Warning: Could not delete test directory {directory} after {maxRetries} attempts. Directory may be cleaned up later."); + } + + [Fact] + public void GetDefaultConfigDirectory_Windows_ReturnsLocalAppData() + { + // Arrange - only run on Windows + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; // Skip on non-Windows + } + + // Act + var configDir = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + + // Assert + Assert.NotNull(configDir); + Assert.Contains("Microsoft.Agents.A365.DevTools.Cli", configDir); + } + + [Fact] + public void GetDefaultConfigDirectory_Linux_UsesXdgPath() + { + // Arrange - only run on Linux/Mac + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; // Skip on Windows + } + + // Save original environment + var originalXdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try + { + // Test 1: XDG_CONFIG_HOME is set + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", "/custom/config"); + var configDir1 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + Assert.Equal("/custom/config/a365", configDir1); + + // Test 2: XDG_CONFIG_HOME not set, HOME is set (default ~/.config/a365) + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", null); + Environment.SetEnvironmentVariable("HOME", "/home/testuser"); + var configDir2 = Microsoft.Agents.A365.DevTools.Cli.Services.ConfigService.GetGlobalConfigDirectory(); + Assert.Equal("/home/testuser/.config/a365", configDir2); + } + finally + { + // Restore original environment + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", originalXdg); + Environment.SetEnvironmentVariable("HOME", originalHome); + } + } +} + +/// +/// Test collection definition that disables parallel execution for config tests. +/// Config tests must run sequentially because they sync files to a shared global directory +/// (%LocalAppData%\Microsoft.Agents.A365.DevTools.Cli on Windows or ~/.config/a365 on Linux/Mac). +/// Running in parallel would cause race conditions and file locking issues. +/// +[CollectionDefinition("ConfigTests", DisableParallelization = true)] +public class ConfigTestCollection +{ + // This class is never instantiated. It exists only to define the collection. +} + +/// +/// Test logger provider that captures log messages to a list +/// +internal class TestLoggerProvider : ILoggerProvider +{ + private readonly List _logMessages; + + public TestLoggerProvider(List logMessages) + { + _logMessages = logMessages; + } + + public ILogger CreateLogger(string categoryName) + { + return new TestLogger(_logMessages); + } + + public void Dispose() { } +} + +/// +/// Test logger that captures messages to a list +/// +internal class TestLogger : ILogger +{ + private readonly List _logMessages; + + public TestLogger(List logMessages) + { + _logMessages = logMessages; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + _logMessages.Add($"[{logLevel}] {message}"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs new file mode 100644 index 00000000..2b84dd4e --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AzureCliServiceTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +public class AzureCliServiceTests +{ + private readonly ILogger _logger; + private readonly CommandExecutor _commandExecutor; + private readonly AzureCliService _azureCliService; + + public AzureCliServiceTests() + { + _logger = Substitute.For>(); + _commandExecutor = Substitute.For(Substitute.For>()); + _azureCliService = new AzureCliService(_commandExecutor, _logger); + } + + [Fact] + public async Task IsLoggedInAsync_WhenAzureCliReturnsSuccess_ReturnsTrue() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "" }; + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromResult(result)); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeTrue(); + } + + [Fact] + public async Task IsLoggedInAsync_WhenAzureCliFails_ReturnsFalse() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Please run 'az login'" }; + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromResult(result)); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeFalse(); + } + + [Fact] + public async Task IsLoggedInAsync_WhenExceptionThrown_ReturnsFalse() + { + // Arrange + _commandExecutor.ExecuteAsync("az", "account show", suppressErrorLogging: true) + .Returns(Task.FromException(new Exception("Azure CLI not found"))); + + // Act + var isLoggedIn = await _azureCliService.IsLoggedInAsync(); + + // Assert + isLoggedIn.Should().BeFalse(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenSuccessful_ReturnsAccountInfo() + { + // Arrange + var jsonOutput = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "name": "Test Subscription", + "tenantId": "87654321-4321-4321-4321-cba987654321", + "user": { + "name": "test@example.com", + "type": "user" + }, + "state": "Enabled", + "isDefault": true + } + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().NotBeNull(); + accountInfo!.Id.Should().Be("12345678-1234-1234-1234-123456789abc"); + accountInfo.Name.Should().Be("Test Subscription"); + accountInfo.TenantId.Should().Be("87654321-4321-4321-4321-cba987654321"); + accountInfo.User.Name.Should().Be("test@example.com"); + accountInfo.User.Type.Should().Be("user"); + accountInfo.State.Should().Be("Enabled"); + accountInfo.IsDefault.Should().BeTrue(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenAzureCliFails_ReturnsNull() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Please run 'az login'" }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().BeNull(); + } + + [Fact] + public async Task GetCurrentAccountAsync_WhenJsonInvalid_ReturnsNull() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "invalid json" }; + _commandExecutor.ExecuteAsync("az", "account show --output json") + .Returns(Task.FromResult(result)); + + // Act + var accountInfo = await _azureCliService.GetCurrentAccountAsync(); + + // Assert + accountInfo.Should().BeNull(); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenSuccessful_ReturnsResourceGroups() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "rg-test-001", + "location": "eastus", + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-001" + }, + { + "name": "rg-test-002", + "location": "westus", + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-002" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().HaveCount(2); + resourceGroups[0].Name.Should().Be("rg-test-001"); + resourceGroups[0].Location.Should().Be("eastus"); + resourceGroups[1].Name.Should().Be("rg-test-002"); + resourceGroups[1].Location.Should().Be("westus"); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenNoResourceGroups_ReturnsEmptyList() + { + // Arrange + var result = new CommandResult { ExitCode = 0, StandardOutput = "[]" }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().BeEmpty(); + } + + [Fact] + public async Task ListResourceGroupsAsync_WhenAzureCliFails_ReturnsEmptyList() + { + // Arrange + var result = new CommandResult { ExitCode = 1, StandardError = "ERROR: Failed to list resource groups" }; + _commandExecutor.ExecuteAsync("az", "group list --output json") + .Returns(Task.FromResult(result)); + + // Act + var resourceGroups = await _azureCliService.ListResourceGroupsAsync(); + + // Assert + resourceGroups.Should().BeEmpty(); + } + + [Fact] + public async Task ListAppServicePlansAsync_WhenSuccessful_ReturnsAppServicePlans() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "asp-test-001", + "resourceGroup": "rg-test-001", + "location": "eastus", + "sku": { + "name": "B1" + }, + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/rg-test-001/providers/Microsoft.Web/serverfarms/asp-test-001" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "appservice plan list --output json") + .Returns(Task.FromResult(result)); + + // Act + var appServicePlans = await _azureCliService.ListAppServicePlansAsync(); + + // Assert + appServicePlans.Should().HaveCount(1); + appServicePlans[0].Name.Should().Be("asp-test-001"); + appServicePlans[0].ResourceGroup.Should().Be("rg-test-001"); + appServicePlans[0].Sku.Should().Be("B1"); + } + + [Fact] + public async Task ListLocationsAsync_WhenSuccessful_ReturnsLocations() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "eastus", + "displayName": "East US", + "regionalDisplayName": "(US) East US" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account list-locations --output json") + .Returns(Task.FromResult(result)); + + // Act + var locations = await _azureCliService.ListLocationsAsync(); + + // Assert + locations.Should().HaveCount(1); + locations[0].Name.Should().Be("eastus"); + locations[0].DisplayName.Should().Be("East US"); + locations[0].RegionalDisplayName.Should().Be("(US) East US"); + } + + [Fact] + public async Task ListLocationsAsync_WhenRegionalDisplayNameMissing_HandlesGracefully() + { + // Arrange + var jsonOutput = """ + [ + { + "name": "eastus", + "displayName": "East US" + } + ] + """; + var result = new CommandResult { ExitCode = 0, StandardOutput = jsonOutput }; + _commandExecutor.ExecuteAsync("az", "account list-locations --output json") + .Returns(Task.FromResult(result)); + + // Act + var locations = await _azureCliService.ListLocationsAsync(); + + // Assert + locations.Should().HaveCount(1); + locations[0].RegionalDisplayName.Should().BeEmpty(); + } +}