Skip to content

Commit a6d7fe2

Browse files
authored
Merge pull request #21 from rian-be/develop
Feat: complete provider aware profile management
2 parents 89c6dea + 198a0f3 commit a6d7fe2

7 files changed

Lines changed: 193 additions & 48 deletions

File tree

src/Cli/Commands/Profiles/Organizations/OrgRemoveCommand.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ internal sealed class OrgRemoveCommand : ICliCommand
3838
public Command Build() =>
3939
new("remove", "Remove an organization")
4040
{
41-
new Argument<string>("name") { Description = "Organization name" }
41+
new Argument<string>("name") { Description = "Organization name" },
42+
new Option<bool>("--yes", "-y") { Description = "Confirm removal without prompting" }
4243
};
43-
}
44+
}

src/Cli/Commands/Profiles/Workspaces/WorkRemoveCommand.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
1515
/// <item>Child command of <see cref="WorkCommand"/>.</item>
1616
/// <item>Execution handled by <see cref="WorkRemoveCommandHandler"/>.</item>
1717
/// <item>Requires workspace name argument.</item>
18-
/// <item>Optionally specifies the organization using <c>--org</c> option.</item>
18+
/// <item>Requires the organization using <c>--org</c> option.</item>
1919
/// <item>Registered automatically as singleton via <see cref="AutoRegisterAttribute"/>.</item>
2020
/// </list>
2121
/// </remarks>
@@ -40,6 +40,7 @@ public Command Build() =>
4040
new("remove", "Remove workspace")
4141
{
4242
new Argument<string>("name") { Description = "Workspace name" },
43-
new Option<string>("--org") { Description = "Organization name" }
43+
new Option<string>("--org") { Description = "Organization name", Required = true },
44+
new Option<bool>("--yes", "-y") { Description = "Confirm removal without prompting" }
4445
};
45-
}
46+
}

src/Cli/Handlers/Profiles/Organizations/OrgCreateCommandHandler.cs

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
using System.CommandLine;
22
using ChangeTrace.Cli.Interfaces;
3+
using ChangeTrace.Cli.Prompts;
34
using ChangeTrace.Configuration.Discovery;
45
using ChangeTrace.CredentialTrace.Interfaces;
56
using ChangeTrace.CredentialTrace.Profiles;
6-
using ChangeTrace.CredentialTrace.Services;
77
using Microsoft.Extensions.DependencyInjection;
88
using Spectre.Console;
99

@@ -23,7 +23,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;
2323
[AutoRegister(ServiceLifetime.Transient, typeof(OrgCreateCommandHandler))]
2424
internal sealed class OrgCreateCommandHandler(
2525
IAuthService auth,
26-
IProfileStore<OrganizationProfile> store) : ICliHandler
26+
IProfileStore<OrganizationProfile> store,
27+
IEnumerable<IAuthProvider> providers) : ICliHandler
2728
{
2829
/// <summary>
2930
/// Executes 'organization create' command asynchronously.
@@ -42,10 +43,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
4243
}
4344

4445
if (string.IsNullOrWhiteSpace(provider))
45-
{
46-
AnsiConsole.MarkupLine("[red]Error:[/] --provider is required.");
46+
provider = ProviderPrompt.SelectProvider(providers);
47+
48+
if (string.IsNullOrWhiteSpace(provider))
4749
return;
48-
}
4950

5051
try
5152
{
@@ -54,19 +55,7 @@ await AnsiConsole.Status()
5455
.SpinnerStyle(Style.Parse("blue"))
5556
.StartAsync($"Creating organization [bold]{name}[/]...", async ctx =>
5657
{
57-
var session = await auth.FetchSession(provider, ct);
58-
59-
var profile = new OrganizationProfile
60-
{
61-
Id = Ulid.NewUlid(),
62-
Name = name,
63-
Provider = provider,
64-
CreatedAt = DateTime.UtcNow,
65-
SessionId = session.Id
66-
};
67-
68-
await store.SaveAsync(profile, ct);
69-
58+
await CreateOrganizationAsync(name, provider, ct);
7059
ctx.Status("Finalizing...");
7160
await Task.Delay(150, ct);
7261
});
@@ -79,6 +68,28 @@ await AnsiConsole.Status()
7968
}
8069
}
8170

71+
/// <summary>
72+
/// Creates and stores organization profile for provider.
73+
/// </summary>
74+
private async Task CreateOrganizationAsync(
75+
string name,
76+
string provider,
77+
CancellationToken ct)
78+
{
79+
var session = await auth.FetchSession(provider, ct);
80+
81+
var profile = new OrganizationProfile
82+
{
83+
Id = Ulid.NewUlid(),
84+
Name = name,
85+
Provider = provider,
86+
CreatedAt = DateTime.UtcNow,
87+
SessionId = session.Id
88+
};
89+
90+
await store.SaveAsync(profile, ct);
91+
}
92+
8293
private static void DisplayConfirmation(string name, string provider)
8394
{
8495
var panel = new Panel(
@@ -92,4 +103,4 @@ private static void DisplayConfirmation(string name, string provider)
92103

93104
AnsiConsole.Write(panel);
94105
}
95-
}
106+
}

src/Cli/Handlers/Profiles/Organizations/OrgListCommandHandler.cs

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.CommandLine;
22
using ChangeTrace.Cli.Interfaces;
3+
using ChangeTrace.Cli.Prompts;
34
using ChangeTrace.Configuration.Discovery;
45
using ChangeTrace.CredentialTrace.Interfaces;
56
using ChangeTrace.CredentialTrace.Profiles;
@@ -23,7 +24,8 @@ namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;
2324
/// </remarks>
2425
[AutoRegister(ServiceLifetime.Transient, typeof(OrgListCommandHandler))]
2526
internal sealed class OrgListCommandHandler(
26-
IProfileStore<OrganizationProfile> store) : ICliHandler
27+
IProfileStore<OrganizationProfile> store,
28+
IEnumerable<IAuthProvider> providers) : ICliHandler
2729
{
2830
/// <summary>
2931
/// Executes 'org list' command asynchronously.
@@ -35,10 +37,10 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
3537
var provider = parseResult.GetValue<string>("--provider");
3638

3739
if (string.IsNullOrWhiteSpace(provider))
38-
{
39-
AnsiConsole.MarkupLine("[red]Error:[/] --provider is required.");
40+
provider = ProviderPrompt.SelectProvider(providers);
41+
42+
if (string.IsNullOrWhiteSpace(provider))
4043
return;
41-
}
4244

4345
try
4446
{
@@ -47,25 +49,15 @@ await AnsiConsole.Status()
4749
.SpinnerStyle(Style.Parse("blue"))
4850
.StartAsync("Loading organizations...", async _ =>
4951
{
50-
var filtered = (await store.GetAllAsync(ct))
51-
.Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))
52-
.ToList();
52+
var filtered = await GetOrganizationsAsync(provider, ct);
5353

5454
if (!filtered.Any())
5555
{
5656
AnsiConsole.MarkupLine("[yellow]No organizations found.[/]");
5757
return;
5858
}
5959

60-
var table = new Table { Border = TableBorder.Rounded };
61-
table.AddColumn("Name");
62-
table.AddColumn("Provider");
63-
64-
foreach (var org in filtered)
65-
table.AddRow(org.Name, org.Provider);
66-
67-
AnsiConsole.Write(table);
68-
60+
DisplayOrganizations(filtered);
6961
DisplayConfirmation(filtered.Count, provider);
7062
});
7163
}
@@ -75,6 +67,28 @@ await AnsiConsole.Status()
7567
}
7668
}
7769

70+
/// <summary>
71+
/// Gets organizations matching provider.
72+
/// </summary>
73+
private async Task<List<OrganizationProfile>> GetOrganizationsAsync(
74+
string provider,
75+
CancellationToken ct) => [.. (await store.GetAllAsync(ct)).Where(o => o.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))];
76+
77+
/// <summary>
78+
/// Displays organizations table.
79+
/// </summary>
80+
private static void DisplayOrganizations(IEnumerable<OrganizationProfile> organizations)
81+
{
82+
var table = new Table { Border = TableBorder.Rounded };
83+
table.AddColumn("Name");
84+
table.AddColumn("Provider");
85+
86+
foreach (var org in organizations)
87+
table.AddRow(org.Name, org.Provider);
88+
89+
AnsiConsole.Write(table);
90+
}
91+
7892
/// <summary>
7993
/// Displays a confirmation panel with the number of organizations found for a given provider.
8094
/// </summary>
Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,59 @@
11
using System.CommandLine;
22
using ChangeTrace.Cli.Interfaces;
33
using ChangeTrace.Configuration.Discovery;
4+
using ChangeTrace.CredentialTrace.Interfaces;
5+
using ChangeTrace.CredentialTrace.Profiles;
46
using Microsoft.Extensions.DependencyInjection;
57
using Spectre.Console;
68

79
namespace ChangeTrace.Cli.Handlers.Profiles.Organizations;
810

911
[AutoRegister(ServiceLifetime.Transient, typeof(OrgRemoveCommandHandler))]
10-
internal sealed class OrgRemoveCommandHandler : ICliHandler
12+
internal sealed class OrgRemoveCommandHandler(
13+
IProfileStore<OrganizationProfile> orgStore,
14+
IWorkspaceStore workspaceStore,
15+
IWorkspaceContext workspaceContext) : ICliHandler
1116
{
12-
public Task HandleAsync(ParseResult parseResult, CancellationToken ct)
17+
public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
1318
{
1419
var name = parseResult.GetValue<string>("name");
20+
var assumeYes = parseResult.GetValue<bool>("--yes");
21+
1522
if (string.IsNullOrWhiteSpace(name))
1623
{
1724
AnsiConsole.MarkupLine("[red]Error:[/] Organization name is required.");
18-
return Task.CompletedTask;
25+
return;
26+
}
27+
28+
var org = await orgStore.GetByNameAsync(name, ct);
29+
if (org == null)
30+
{
31+
AnsiConsole.MarkupLine($"[red]Organization '{name}' not found.[/]");
32+
return;
33+
}
34+
35+
var workspaces = (await workspaceStore.GetByNameOrganization(org.Name, ct)).ToList();
36+
if (workspaces.Any(w => workspaceContext.Current?.Id == w.Id))
37+
{
38+
AnsiConsole.MarkupLine("[red]Cannot remove an organization that contains the active workspace. Select another workspace first.[/]");
39+
return;
1940
}
2041

21-
return null;
42+
var workspaceSummary = workspaces.Count == 0
43+
? "no workspaces"
44+
: $"{workspaces.Count} workspace(s)";
45+
46+
if (!assumeYes && !AnsiConsole.Confirm($"Remove organization [yellow]{org.Name}[/] and {workspaceSummary}?"))
47+
{
48+
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
49+
return;
50+
}
51+
52+
foreach (var workspace in workspaces)
53+
await workspaceStore.DeleteAsync(workspace.Id, ct);
54+
55+
await orgStore.DeleteAsync(org.Id, ct);
56+
57+
AnsiConsole.MarkupLine($"[green]Removed organization '{org.Name}' and {workspaces.Count} workspace(s).[/]");
2258
}
23-
}
59+
}
Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,62 @@
11
using System.CommandLine;
22
using ChangeTrace.Cli.Interfaces;
33
using ChangeTrace.Configuration.Discovery;
4+
using ChangeTrace.CredentialTrace.Interfaces;
5+
using ChangeTrace.CredentialTrace.Profiles;
46
using Microsoft.Extensions.DependencyInjection;
7+
using Spectre.Console;
58

69
namespace ChangeTrace.Cli.Handlers.Profiles.Workspaces;
710

811
[AutoRegister(ServiceLifetime.Transient, typeof(WorkRemoveCommandHandler))]
9-
internal sealed class WorkRemoveCommandHandler : ICliHandler
12+
internal sealed class WorkRemoveCommandHandler(
13+
IProfileStore<OrganizationProfile> orgStore,
14+
IWorkspaceStore workspaceStore,
15+
IWorkspaceContext workspaceContext) : ICliHandler
1016
{
1117
public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
18+
=> await HandleAsync(
19+
parseResult.GetValue<string>("name")!,
20+
parseResult.GetValue<string>("--org")!,
21+
parseResult.GetValue<bool>("--yes"),
22+
ct);
23+
24+
private async Task HandleAsync(
25+
string name,
26+
string orgName,
27+
bool assumeYes,
28+
CancellationToken ct)
1229
{
13-
30+
var org = await orgStore.GetByNameAsync(orgName, ct);
31+
if (org is null)
32+
{
33+
AnsiConsole.MarkupLine($"[red]Organization '{orgName}' not found.[/]");
34+
return;
35+
}
36+
37+
var workspaces = await workspaceStore.GetByNameOrganization(orgName, ct);
38+
var workspace = workspaces.FirstOrDefault(w =>
39+
w.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
40+
41+
if (workspace is null)
42+
{
43+
AnsiConsole.MarkupLine($"[red]Workspace '{name}' not found in organization '{orgName}'.[/]");
44+
return;
45+
}
46+
47+
if (workspaceContext.Current?.Id == workspace.Id)
48+
{
49+
AnsiConsole.MarkupLine("[red]Cannot remove the active workspace. Select another workspace first.[/]");
50+
return;
51+
}
52+
53+
if (!assumeYes && !AnsiConsole.Confirm($"Remove workspace [yellow]{workspace.Name}[/] from [yellow]{org.Name}[/]?"))
54+
{
55+
AnsiConsole.MarkupLine("[yellow]Cancelled.[/]");
56+
return;
57+
}
58+
59+
await workspaceStore.DeleteAsync(workspace.Id, ct);
60+
AnsiConsole.MarkupLine($"[green]Removed workspace '{workspace.Name}' from organization '{org.Name}'.[/]");
1461
}
15-
}
62+
}

src/Cli/Prompts/ProviderPrompt.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using ChangeTrace.CredentialTrace.Interfaces;
2+
using Spectre.Console;
3+
4+
namespace ChangeTrace.Cli.Prompts;
5+
6+
/// <summary>
7+
/// Prompts for authentication provider selection.
8+
/// </summary>
9+
internal static class ProviderPrompt
10+
{
11+
/// <summary>
12+
/// Selects provider from registered authentication providers.
13+
/// </summary>
14+
public static string? SelectProvider(IEnumerable<IAuthProvider> providers)
15+
{
16+
var choices = providers
17+
.Select(p => p.Name)
18+
.Where(name => !string.IsNullOrWhiteSpace(name))
19+
.Distinct(StringComparer.OrdinalIgnoreCase)
20+
.OrderBy(name => name)
21+
.ToArray();
22+
23+
if (choices.Length == 0)
24+
{
25+
AnsiConsole.MarkupLine("[red]No authentication providers are registered.[/]");
26+
return null;
27+
}
28+
29+
return AnsiConsole.Prompt(
30+
new SelectionPrompt<string>()
31+
.Title("Select authentication provider")
32+
.PageSize(8)
33+
.AddChoices(choices));
34+
}
35+
}

0 commit comments

Comments
 (0)