diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs index 7b3fd0ba..c4d1ebce 100644 --- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs @@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment; Name = "environment", Alias = "env", Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).", - Children = new[] { typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, + Children = new[] { typeof(EnvironmentListCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) }, ShortFormAutoGenerate = CliNameAutoGenerate.None )] public class EnvironmentCliCommand diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs new file mode 100644 index 00000000..fbd83ac8 --- /dev/null +++ b/src/TALXIS.CLI.Features.Environment/EnvironmentListCliCommand.cs @@ -0,0 +1,108 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.DependencyInjection; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Logging; +using TALXIS.CLI.Platform.PowerPlatform.Control; + +namespace TALXIS.CLI.Features.Environment; + +[CliReadOnly] +[CliCommand( + Name = "list", + Description = "List Dataverse environments you can access." +)] +public class EnvironmentListCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EnvironmentListCliCommand)); + + [CliOption( + Name = "--credential", + Aliases = ["-c"], + Description = "Credential alias used to query environments. Defaults to the only stored credential; required when more than one exists.", + Required = false + )] + public string? Credential { get; set; } + + protected override async Task ExecuteAsync() + { + var store = TxcServices.Get(); + var credential = await ResolveCredentialAsync(store).ConfigureAwait(false); + if (credential is null) return ExitValidationError; + + var connection = new Connection + { + Id = "(discovery)", + Provider = ProviderKind.Dataverse, + Cloud = credential.Cloud ?? CloudInstance.Public, + TenantId = credential.TenantId, + }; + + var catalog = TxcServices.Get(); + var environments = await catalog.ListAsync(connection, credential, CancellationToken.None).ConfigureAwait(false); + + var rows = environments + .OrderBy(e => e.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(e => new EnvRow( + e.DisplayName, + e.EnvironmentUrl.ToString(), + e.EnvironmentType?.ToString(), + e.EnvironmentId, + e.OrganizationId, + credential.TenantId)) + .ToList(); + + OutputFormatter.WriteList(rows, EnvironmentListPrinter.PrintTable); + return ExitSuccess; + } + + private async Task ResolveCredentialAsync(ICredentialStore store) + { + if (!string.IsNullOrWhiteSpace(Credential)) + { + var alias = Credential.Trim(); + var match = await store.GetAsync(alias, CancellationToken.None).ConfigureAwait(false); + if (match is null) Logger.LogError("Credential '{Alias}' was not found. Run 'txc config auth list' to see stored credentials.", alias); + return match; + } + + var all = await store.ListAsync(CancellationToken.None).ConfigureAwait(false); + if (all.Count == 0) + { + Logger.LogError("No stored credentials. Sign in first with 'txc config auth login', then retry."); + return null; + } + if (all.Count > 1) + { + var aliases = string.Join(", ", all.Select(c => c.Id).OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + Logger.LogError("Several credentials are stored ({Aliases}). Pick one with --credential .", aliases); + return null; + } + return all[0]; + } +} + +internal sealed record EnvRow(string Name, string Url, string? Type, Guid EnvironmentId, Guid? OrganizationId, string? TenantId); + +internal static class EnvironmentListPrinter +{ + public static void PrintTable(IReadOnlyList rows) + { + if (rows.Count == 0) + { + OutputWriter.WriteLine("No environments found."); + return; + } + + OutputWriter.WriteLine($" {"NAME",-32} {"TYPE",-12} URL"); + foreach (var r in rows) + { + OutputWriter.WriteLine($" {Trim(r.Name, 32),-32} {r.Type ?? "-",-12} {r.Url}"); + } + } + + private static string Trim(string value, int max) + => value.Length <= max ? value : value[..(max - 1)] + "..."; +} diff --git a/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj index 0fcb9bb3..95109f79 100644 --- a/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj +++ b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/TALXIS.CLI.Tests/Environment/EnvironmentListCommandTests.cs b/tests/TALXIS.CLI.Tests/Environment/EnvironmentListCommandTests.cs new file mode 100644 index 00000000..82856ffb --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Environment/EnvironmentListCommandTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using TALXIS.CLI.Core.Abstractions; +using TALXIS.CLI.Core.Model; +using TALXIS.CLI.Features.Environment; +using TALXIS.CLI.Platform.PowerPlatform.Control; +using TALXIS.CLI.Tests.Config.Commands; +using Xunit; + +namespace TALXIS.CLI.Tests.Environment; + +[Collection("Sequential")] +public class EnvironmentListCommandTests +{ + private static async Task SeedCredentialAsync(CommandTestHost host, string id) + { + var store = host.Provider.GetRequiredService(); + await store.UpsertAsync(new Credential + { + Id = id, + Kind = CredentialKind.InteractiveBrowser, + TenantId = "tenant-guid", + Cloud = CloudInstance.Public, + }, default); + } + + private static void SeedEnvironment(CommandTestHost host, string name, string url) + { + host.EnvironmentCatalog.Add(new PowerPlatformEnvironmentSummary( + EnvironmentId: Guid.NewGuid(), + DisplayName: name, + EnvironmentUrl: new Uri(url), + UniqueName: null, + DomainName: null, + OrganizationId: Guid.NewGuid(), + EnvironmentType: EnvironmentType.Sandbox)); + } + + [Fact] + public async Task List_SingleCredential_ReturnsSuccess() + { + using var host = new CommandTestHost(); + await SeedCredentialAsync(host, "only-cred"); + SeedEnvironment(host, "Dev", "https://dev.crm4.dynamics.com/"); + + var exit = await new EnvironmentListCliCommand().RunAsync(); + + Assert.Equal(0, exit); + } + + [Fact] + public async Task List_NoCredentials_ReturnsValidationError() + { + using var host = new CommandTestHost(); + + var exit = await new EnvironmentListCliCommand().RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task List_MultipleCredentialsWithoutFlag_ReturnsValidationError() + { + using var host = new CommandTestHost(); + await SeedCredentialAsync(host, "cred-a"); + await SeedCredentialAsync(host, "cred-b"); + + var exit = await new EnvironmentListCliCommand().RunAsync(); + + Assert.Equal(2, exit); + } + + [Fact] + public async Task List_ExplicitCredential_ReturnsSuccess() + { + using var host = new CommandTestHost(); + await SeedCredentialAsync(host, "cred-a"); + await SeedCredentialAsync(host, "cred-b"); + SeedEnvironment(host, "Dev", "https://dev.crm4.dynamics.com/"); + + var exit = await new EnvironmentListCliCommand { Credential = "cred-b" }.RunAsync(); + + Assert.Equal(0, exit); + } + + [Fact] + public async Task List_UnknownCredential_ReturnsValidationError() + { + using var host = new CommandTestHost(); + await SeedCredentialAsync(host, "cred-a"); + + var exit = await new EnvironmentListCliCommand { Credential = "does-not-exist" }.RunAsync(); + + Assert.Equal(2, exit); + } +}