diff --git a/src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs b/src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs index fac0679..1c32f0d 100644 --- a/src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs +++ b/src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs @@ -38,6 +38,7 @@ internal sealed class OrgRemoveCommand : ICliCommand public Command Build() => new("remove", "Remove an organization") { - new Argument("name") { Description = "Organization name" } + new Argument("name") { Description = "Organization name" }, + new Option("--yes", "-y") { Description = "Confirm removal without prompting" } }; -} \ No newline at end of file +} diff --git a/src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs b/src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs index db5efcc..16c2fc6 100644 --- a/src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs +++ b/src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs @@ -15,7 +15,7 @@ namespace ChangeTrace.Cli.Commands.Profiles.Workspaces; /// Child command of . /// Execution handled by . /// Requires workspace name argument. -/// Optionally specifies the organization using --org option. +/// Requires the organization using --org option. /// Registered automatically as singleton via . /// /// @@ -40,6 +40,7 @@ public Command Build() => new("remove", "Remove workspace") { new Argument("name") { Description = "Workspace name" }, - new Option("--org") { Description = "Organization name" } + new Option("--org") { Description = "Organization name", Required = true }, + new Option("--yes", "-y") { Description = "Confirm removal without prompting" } }; -} \ No newline at end of file +} diff --git a/src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs b/src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs index 7116625..e8df5a7 100644 --- a/src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs @@ -1,9 +1,9 @@ using System.CommandLine; using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Cli.Prompts; using ChangeTrace.Configuration.Discovery; using ChangeTrace.CredentialTrace.Interfaces; using ChangeTrace.CredentialTrace.Profiles; -using ChangeTrace.CredentialTrace.Services; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; @@ -23,7 +23,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations; [AutoRegister(ServiceLifetime.Transient, typeof(OrgCreateCommandHandler))] internal sealed class OrgCreateCommandHandler( IAuthService auth, - IProfileStore store) : ICliHandler + IProfileStore store, + IEnumerable providers) : ICliHandler { /// /// Executes 'organization create' command asynchronously. @@ -42,10 +43,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) } if (string.IsNullOrWhiteSpace(provider)) - { - AnsiConsole.MarkupLine("[red]Error:[/] --provider is required."); + provider = ProviderPrompt.SelectProvider(providers); + + if (string.IsNullOrWhiteSpace(provider)) return; - } try { @@ -54,19 +55,7 @@ await AnsiConsole.Status() .SpinnerStyle(Style.Parse("blue")) .StartAsync($"Creating organization [bold]{name}[/]...", async ctx => { - var session = await auth.FetchSession(provider, ct); - - var profile = new OrganizationProfile - { - Id = Ulid.NewUlid(), - Name = name, - Provider = provider, - CreatedAt = DateTime.UtcNow, - SessionId = session.Id - }; - - await store.SaveAsync(profile, ct); - + await CreateOrganizationAsync(name, provider, ct); ctx.Status("Finalizing..."); await Task.Delay(150, ct); }); @@ -79,6 +68,28 @@ await AnsiConsole.Status() } } + /// + /// Creates and stores organization profile for provider. + /// + private async Task CreateOrganizationAsync( + string name, + string provider, + CancellationToken ct) + { + var session = await auth.FetchSession(provider, ct); + + var profile = new OrganizationProfile + { + Id = Ulid.NewUlid(), + Name = name, + Provider = provider, + CreatedAt = DateTime.UtcNow, + SessionId = session.Id + }; + + await store.SaveAsync(profile, ct); + } + private static void DisplayConfirmation(string name, string provider) { var panel = new Panel( @@ -92,4 +103,4 @@ private static void DisplayConfirmation(string name, string provider) AnsiConsole.Write(panel); } -} \ No newline at end of file +} diff --git a/src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs b/src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs index 8a0749a..fd2194a 100644 --- a/src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs @@ -1,5 +1,6 @@ using System.CommandLine; using ChangeTrace.Cli.Interfaces; +using ChangeTrace.Cli.Prompts; using ChangeTrace.Configuration.Discovery; using ChangeTrace.CredentialTrace.Interfaces; using ChangeTrace.CredentialTrace.Profiles; @@ -23,7 +24,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations; /// [AutoRegister(ServiceLifetime.Transient, typeof(OrgListCommandHandler))] internal sealed class OrgListCommandHandler( - IProfileStore store) : ICliHandler + IProfileStore store, + IEnumerable providers) : ICliHandler { /// /// Executes 'org list' command asynchronously. @@ -35,10 +37,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) var provider = parseResult.GetValue("--provider"); if (string.IsNullOrWhiteSpace(provider)) - { - AnsiConsole.MarkupLine("[red]Error:[/] --provider is required."); + provider = ProviderPrompt.SelectProvider(providers); + + if (string.IsNullOrWhiteSpace(provider)) return; - } try { @@ -47,9 +49,7 @@ await AnsiConsole.Status() .SpinnerStyle(Style.Parse("blue")) .StartAsync("Loading organizations...", async _ => { - var filtered = (await store.GetAllAsync(ct)) - .Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase)) - .ToList(); + var filtered = await GetOrganizationsAsync(provider, ct); if (!filtered.Any()) { @@ -57,15 +57,7 @@ await AnsiConsole.Status() return; } - var table = new Table { Border = TableBorder.Rounded }; - table.AddColumn("Name"); - table.AddColumn("Provider"); - - foreach (var org in filtered) - table.AddRow(org.Name, org.Provider); - - AnsiConsole.Write(table); - + DisplayOrganizations(filtered); DisplayConfirmation(filtered.Count, provider); }); } @@ -75,6 +67,28 @@ await AnsiConsole.Status() } } + /// + /// Gets organizations matching provider. + /// + private async Task> GetOrganizationsAsync( + string provider, + CancellationToken ct) => [.. (await store.GetAllAsync(ct)).Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))]; + + /// + /// Displays organizations table. + /// + private static void DisplayOrganizations(IEnumerable organizations) + { + var table = new Table { Border = TableBorder.Rounded }; + table.AddColumn("Name"); + table.AddColumn("Provider"); + + foreach (var org in organizations) + table.AddRow(org.Name, org.Provider); + + AnsiConsole.Write(table); + } + /// /// Displays a confirmation panel with the number of organizations found for a given provider. /// diff --git a/src/Cli/Handlers/Profiles/Organizations/OrgRemoveCommandHandler.cs b/src/Cli/Handlers/Profiles/Organizations/OrgRemoveCommandHandler.cs index 4d535aa..5276afb 100644 --- a/src/Cli/Handlers/Profiles/Organizations/OrgRemoveCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Organizations/OrgRemoveCommandHandler.cs @@ -1,23 +1,59 @@ using System.CommandLine; using ChangeTrace.Cli.Interfaces; using ChangeTrace.Configuration.Discovery; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; namespace ChangeTrace.Cli.Handlers.Profiles.Organizations; [AutoRegister(ServiceLifetime.Transient, typeof(OrgRemoveCommandHandler))] -internal sealed class OrgRemoveCommandHandler : ICliHandler +internal sealed class OrgRemoveCommandHandler( + IProfileStore orgStore, + IWorkspaceStore workspaceStore, + IWorkspaceContext workspaceContext) : ICliHandler { - public Task HandleAsync(ParseResult parseResult, CancellationToken ct) + public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) { var name = parseResult.GetValue("name"); + var assumeYes = parseResult.GetValue("--yes"); + if (string.IsNullOrWhiteSpace(name)) { AnsiConsole.MarkupLine("[red]Error:[/] Organization name is required."); - return Task.CompletedTask; + return; + } + + var org = await orgStore.GetByNameAsync(name, ct); + if (org == null) + { + AnsiConsole.MarkupLine($"[red]Organization '{name}' not found.[/]"); + return; + } + + var workspaces = (await workspaceStore.GetByNameOrganization(org.Name, ct)).ToList(); + if (workspaces.Any(w => workspaceContext.Current?.Id == w.Id)) + { + AnsiConsole.MarkupLine("[red]Cannot remove an organization that contains the active workspace. Select another workspace first.[/]"); + return; } - return null; + var workspaceSummary = workspaces.Count == 0 + ? "no workspaces" + : $"{workspaces.Count} workspace(s)"; + + if (!assumeYes && !AnsiConsole.Confirm($"Remove organization [yellow]{org.Name}[/] and {workspaceSummary}?")) + { + AnsiConsole.MarkupLine("[yellow]Cancelled.[/]"); + return; + } + + foreach (var workspace in workspaces) + await workspaceStore.DeleteAsync(workspace.Id, ct); + + await orgStore.DeleteAsync(org.Id, ct); + + AnsiConsole.MarkupLine($"[green]Removed organization '{org.Name}' and {workspaces.Count} workspace(s).[/]"); } -} \ No newline at end of file +} diff --git a/src/Cli/Handlers/Profiles/Workspaces/WorkRemoveCommandHandler.cs b/src/Cli/Handlers/Profiles/Workspaces/WorkRemoveCommandHandler.cs index 1110133..966fd4d 100644 --- a/src/Cli/Handlers/Profiles/Workspaces/WorkRemoveCommandHandler.cs +++ b/src/Cli/Handlers/Profiles/Workspaces/WorkRemoveCommandHandler.cs @@ -1,15 +1,62 @@ using System.CommandLine; using ChangeTrace.Cli.Interfaces; using ChangeTrace.Configuration.Discovery; +using ChangeTrace.CredentialTrace.Interfaces; +using ChangeTrace.CredentialTrace.Profiles; using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces; [AutoRegister(ServiceLifetime.Transient, typeof(WorkRemoveCommandHandler))] -internal sealed class WorkRemoveCommandHandler : ICliHandler +internal sealed class WorkRemoveCommandHandler( + IProfileStore orgStore, + IWorkspaceStore workspaceStore, + IWorkspaceContext workspaceContext) : ICliHandler { public async Task HandleAsync(ParseResult parseResult, CancellationToken ct) + => await HandleAsync( + parseResult.GetValue("name")!, + parseResult.GetValue("--org")!, + parseResult.GetValue("--yes"), + ct); + + private async Task HandleAsync( + string name, + string orgName, + bool assumeYes, + CancellationToken ct) { - + var org = await orgStore.GetByNameAsync(orgName, ct); + if (org is null) + { + AnsiConsole.MarkupLine($"[red]Organization '{orgName}' not found.[/]"); + return; + } + + var workspaces = await workspaceStore.GetByNameOrganization(orgName, ct); + var workspace = workspaces.FirstOrDefault(w => + w.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + if (workspace is null) + { + AnsiConsole.MarkupLine($"[red]Workspace '{name}' not found in organization '{orgName}'.[/]"); + return; + } + + if (workspaceContext.Current?.Id == workspace.Id) + { + AnsiConsole.MarkupLine("[red]Cannot remove the active workspace. Select another workspace first.[/]"); + return; + } + + if (!assumeYes && !AnsiConsole.Confirm($"Remove workspace [yellow]{workspace.Name}[/] from [yellow]{org.Name}[/]?")) + { + AnsiConsole.MarkupLine("[yellow]Cancelled.[/]"); + return; + } + + await workspaceStore.DeleteAsync(workspace.Id, ct); + AnsiConsole.MarkupLine($"[green]Removed workspace '{workspace.Name}' from organization '{org.Name}'.[/]"); } -} \ No newline at end of file +} diff --git a/src/Cli/Prompts/ProviderPrompt.cs b/src/Cli/Prompts/ProviderPrompt.cs new file mode 100644 index 0000000..6bb8da3 --- /dev/null +++ b/src/Cli/Prompts/ProviderPrompt.cs @@ -0,0 +1,35 @@ +using ChangeTrace.CredentialTrace.Interfaces; +using Spectre.Console; + +namespace ChangeTrace.Cli.Prompts; + +/// +/// Prompts for authentication provider selection. +/// +internal static class ProviderPrompt +{ + /// + /// Selects provider from registered authentication providers. + /// + public static string? SelectProvider(IEnumerable providers) + { + var choices = providers + .Select(p => p.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToArray(); + + if (choices.Length == 0) + { + AnsiConsole.MarkupLine("[red]No authentication providers are registered.[/]"); + return null; + } + + return AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select authentication provider") + .PageSize(8) + .AddChoices(choices)); + } +}