diff --git a/src/FaluCli/Client/FaluCliClient.cs b/src/FaluCli/Client/FaluCliClient.cs index a8c98a38..32e461e4 100644 --- a/src/FaluCli/Client/FaluCliClient.cs +++ b/src/FaluCli/Client/FaluCliClient.cs @@ -1,6 +1,7 @@ using Falu.Client.Events; using Falu.Client.MoneyStatements; using Falu.Client.Realtime; +using Falu.Client.Workspaces; using Microsoft.Extensions.Options; namespace Falu.Client; @@ -13,9 +14,11 @@ public FaluCliClient(HttpClient backChannel, IOptionsSnapshot Events = new ExtendedEventsServiceClient(BackChannel, Options); MoneyStatements = new MoneyStatementsServiceClient(BackChannel, Options); Realtime = new RealtimeServiceClient(BackChannel, Options); + Workspaces = new WorkspacesServiceClient(BackChannel, Options); } public new ExtendedEventsServiceClient Events { get; protected set; } public MoneyStatementsServiceClient MoneyStatements { get; protected set; } public RealtimeServiceClient Realtime { get; protected set; } + public WorkspacesServiceClient Workspaces { get; protected set; } } diff --git a/src/FaluCli/Client/FaluCliClientHandler.cs b/src/FaluCli/Client/FaluCliClientHandler.cs index f3f09860..6ff32cee 100644 --- a/src/FaluCli/Client/FaluCliClientHandler.cs +++ b/src/FaluCli/Client/FaluCliClientHandler.cs @@ -21,20 +21,25 @@ protected override async Task SendAsync(HttpRequestMessage var key = parseResult.ValueForOption("--apikey"); if (string.IsNullOrWhiteSpace(key)) { - // (1) Set the X-Workspace-Id header using the CLI option to override the default - if (!parseResult.TryGetWorkspaceId(out var workspaceId) && (workspaceId = configValues.DefaultWorkspaceId) == null) + // (1) Set the X-Workspace-Id and X-Live-Mode headers but skip for /workspaces + if (!request.RequestUri!.ToString().Contains("/workspaces")) { - throw new FaluException(Res.MissingWorkspaceId); - } - request.Headers.Replace("X-Workspace-Id", workspaceId); + // (1a) Set the X-Workspace-Id header using the CLI option to override the default + if (parseResult.TryGetWorkspaceId(out var workspaceId)) + { + // TODO: check if the workspace exists in the configuration + } + workspaceId ??= (workspaceId ?? configValues.DefaultWorkspaceId) ?? throw new FaluException(Res.MissingWorkspaceId); + request.Headers.Replace("X-Workspace-Id", workspaceId); - // (2) Set the X-Live-Mode header using CLI option to override the default - if (parseResult.TryGetLiveMode(out var live) || (live = configValues.DefaultLiveMode) != null) - { - request.Headers.Replace("X-Live-Mode", live.Value.ToString().ToLowerInvariant()); // when absent, the server assumes false + // (1b) Set the X-Live-Mode header using CLI option to override the default + if (parseResult.TryGetLiveMode(out var live) || (live = configValues.DefaultLiveMode) != null) + { + request.Headers.Replace("X-Live-Mode", live.Value.ToString().ToLowerInvariant()); // when absent, the server assumes false + } } - // (3) Handle appropriate authentication + // (2) Handle appropriate authentication // ensure we have login information and that it contains a valid access token or refresh token if (configValues.Authentication is null || (!configValues.Authentication.HasValidAccessToken() && !configValues.Authentication.HasRefreshToken())) @@ -65,7 +70,7 @@ protected override async Task SendAsync(HttpRequestMessage // at this point we have a key and we can proceed to set the authentication header request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key); - // (5) Execute the modified request + // (3) Execute the modified request return await base.SendAsync(request, cancellationToken); } } diff --git a/src/FaluCli/Client/Workspaces/Workspace.cs b/src/FaluCli/Client/Workspaces/Workspace.cs new file mode 100644 index 00000000..80b1824e --- /dev/null +++ b/src/FaluCli/Client/Workspaces/Workspace.cs @@ -0,0 +1,15 @@ +using Falu.Core; + +namespace Falu.Client.Workspaces; + +internal class Workspace : IHasId +{ + /// + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Status { get; set; } + + public string? Role { get; set; } +} diff --git a/src/FaluCli/Client/Workspaces/WorkspacesListOptions.cs b/src/FaluCli/Client/Workspaces/WorkspacesListOptions.cs new file mode 100644 index 00000000..6f9b8c63 --- /dev/null +++ b/src/FaluCli/Client/Workspaces/WorkspacesListOptions.cs @@ -0,0 +1,6 @@ +using Falu.Core; + +namespace Falu.Client.Workspaces; + +/// Options for filtering and pagination of workspaces. +public record WorkspacesListOptions : BasicListOptions { } // intentionally left blank diff --git a/src/FaluCli/Client/Workspaces/WorkspacesServiceClient.cs b/src/FaluCli/Client/Workspaces/WorkspacesServiceClient.cs new file mode 100644 index 00000000..678bec32 --- /dev/null +++ b/src/FaluCli/Client/Workspaces/WorkspacesServiceClient.cs @@ -0,0 +1,30 @@ +using Falu.Core; +using SC = Falu.FaluCliJsonSerializerContext; + +namespace Falu.Client.Workspaces; + +internal class WorkspacesServiceClient(HttpClient backChannel, FaluClientOptions options) : BaseServiceClient(backChannel, options, SC.Default.Workspace, SC.Default.ListWorkspace), + ISupportsListing +{ + /// + protected override string BasePath => "/v1/workspaces"; + + + /// Retrieve workspaces. + /// + public virtual Task>> ListAsync(WorkspacesListOptions? options = null, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + { + return ListResourcesAsync(options, requestOptions, cancellationToken); + } + + /// Retrieve workspaces recursively. + /// + public virtual IAsyncEnumerable ListRecursivelyAsync(WorkspacesListOptions? options = null, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + { + return ListResourcesRecursivelyAsync(options, requestOptions, cancellationToken); + } +} diff --git a/src/FaluCli/Commands/Login/LoginCommand.cs b/src/FaluCli/Commands/Login/LoginCommand.cs index b46a143c..2798b148 100644 --- a/src/FaluCli/Commands/Login/LoginCommand.cs +++ b/src/FaluCli/Commands/Login/LoginCommand.cs @@ -77,6 +77,29 @@ public override async Task ExecuteAsync(CliCommandExecutionContext context, // set the authentication information context.ConfigValues.Authentication = new ConfigValuesAuthenticationTokens(tokenResp); + // sync workspaces + context.Logger.LogInformation("Syncing workspaces ..."); + var workspacesResp = await context.Client.Workspaces.ListAsync(cancellationToken: cancellationToken); + workspacesResp.EnsureSuccess(); + var workspaces = workspacesResp.Resource!.Select(w => new ConfigValuesWorkspace(w)).ToList(); + context.ConfigValues.Workspaces = workspaces; + context.Logger.LogInformation("Workspaces synced successfully."); + + // update default workspace + if (workspaces.Count > 0) + { + var defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId; + if (defaultWorkspaceId is not null) + { + var workspace = context.ConfigValues.GetWorkspace(defaultWorkspaceId); + if (workspace is null) + { + context.Logger.LogInformation("Default workspace '{DefaultWorkspaceId}' not found. Resetting to null.", defaultWorkspaceId); + context.ConfigValues.DefaultWorkspaceId = null; + } + } + } + return 0; } } diff --git a/src/FaluCli/Commands/Login/LogoutCommand.cs b/src/FaluCli/Commands/Login/LogoutCommand.cs index f52be8a4..1d16c3b1 100644 --- a/src/FaluCli/Commands/Login/LogoutCommand.cs +++ b/src/FaluCli/Commands/Login/LogoutCommand.cs @@ -7,6 +7,7 @@ public LogoutCommand() : base("logout", "Logout of your Falu account from the CL public override Task ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken) { context.ConfigValues.Authentication = null; + context.ConfigValues.Workspaces = []; context.Logger.LogInformation("Authentication information cleared."); return Task.FromResult(0); diff --git a/src/FaluCli/Commands/WorkspacesCommand.cs b/src/FaluCli/Commands/WorkspacesCommand.cs new file mode 100644 index 00000000..bec5bd07 --- /dev/null +++ b/src/FaluCli/Commands/WorkspacesCommand.cs @@ -0,0 +1,110 @@ +using Falu.Config; +using Spectre.Console; + +namespace Falu.Commands; + +internal class WorkspacesCommand : CliCommand +{ + public WorkspacesCommand() : base("workspaces", "Manage workspaces") + { + Add(new WorkspacesListCommand()); + Add(new WorkspacesShowCommand()); + } +} + +internal class WorkspacesListCommand : FaluCliCommand +{ + public WorkspacesListCommand() : base("list", "Get a list of workspaces for the logged in account.\r\nBy default, 'Terminated' workspaces are not shown.") + { + this.AddOption(aliases: ["--all"], + description: "List all workspaces, rather than skipping 'Terminated' ones.", + defaultValue: false); + + this.AddOption(aliases: ["--refresh"], + description: "Retrieve up-to-date workspaces from server.", + defaultValue: false); + } + + public override async Task ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken) + { + var all = context.ParseResult.ValueForOption("--all"); + var refresh = context.ParseResult.ValueForOption("--refresh"); + + var defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId; + + var workspaces = context.ConfigValues.Workspaces.ToList(); + if (refresh) + { + var response = await context.Client.Workspaces.ListAsync(cancellationToken: cancellationToken); + response.EnsureSuccess(); + workspaces = response.Resource!.Select(w => new ConfigValuesWorkspace(w)).ToList(); + context.ConfigValues.Workspaces = workspaces; // update them so that they are saved + + // update default workspace + if (workspaces.Count > 0) + { + if (defaultWorkspaceId is not null) + { + var workspace = context.ConfigValues.GetWorkspace(defaultWorkspaceId); + if (workspace is null) + { + context.Logger.LogInformation("Default workspace '{DefaultWorkspaceId}' not found. Resetting to null.", defaultWorkspaceId); + defaultWorkspaceId = context.ConfigValues.DefaultWorkspaceId = null; + } + } + } + } + + workspaces = all ? workspaces : workspaces.Where(w => !string.Equals(w.Status, "terminated", StringComparison.OrdinalIgnoreCase)).ToList(); + + var table = new Table().AddColumn("Name") + .AddColumn("Id") + .AddColumn("Status") + .AddColumn(new TableColumn("Default").Centered()); + + foreach (var workspace in workspaces) + { + var isDefault = string.Equals(workspace.Id, defaultWorkspaceId, StringComparison.OrdinalIgnoreCase); + table.AddRow(new Markup(workspace.Name), + new Markup(workspace.Id), + new Markup(workspace.Status), + new Markup(isDefault ? SpectreFormatter.ColouredGreen("✔ YES") : "")); + } + + AnsiConsole.Write(table); + + return 0; + } +} + +internal class WorkspacesShowCommand : FaluCliCommand +{ + public WorkspacesShowCommand() : base("show", " Get the details of a workspace.") + { + this.AddOption(aliases: ["--name", "-n"], + description: "Name or ID of workspace.", + configure: o => o.Required = true); + } + + public override Task ExecuteAsync(CliCommandExecutionContext context, CancellationToken cancellationToken) + { + var name = context.ParseResult.ValueForOption("--name")!; + + var workspace = context.ConfigValues.GetRequiredWorkspace(name); + + var table = new Table().AddColumn("Name") + .AddColumn("Id") + .AddColumn("Status") + .AddColumn(new TableColumn("Default").Centered()); + + var isDefault = string.Equals(workspace.Id, context.ConfigValues.DefaultWorkspaceId, StringComparison.OrdinalIgnoreCase); + table.AddRow(new Markup(workspace.Name), + new Markup(workspace.Id), + new Markup(workspace.Status), + new Markup(isDefault ? SpectreFormatter.ColouredGreen("✔ YES") : "")); + + AnsiConsole.Write(table); + + return Task.FromResult(0); + } +} diff --git a/src/FaluCli/Config/ConfigValues.cs b/src/FaluCli/Config/ConfigValues.cs index 4f23f287..56839e24 100644 --- a/src/FaluCli/Config/ConfigValues.cs +++ b/src/FaluCli/Config/ConfigValues.cs @@ -18,6 +18,7 @@ internal sealed class ConfigValues(JsonObject inner) : AbstractConfigValues(inne private const string KeyDefaultWorkspaceId = "default_workspace_id"; private const string KeyDefaultLiveMode = "default_live_mode"; private const string KeyAuthentication = "authentication"; + private const string KeyWorkspaces = "workspaces"; public bool NoTelemetry { get => GetPrimitiveValue(KeyNoTelemetry, false); set => SetValue(KeyNoTelemetry, value); } public bool NoUpdates { get => GetPrimitiveValue(KeyNoUpdates, false); set => SetValue(KeyNoUpdates, value); } @@ -33,6 +34,20 @@ public ConfigValuesAuthenticationTokens? Authentication set => SetValue(KeyAuthentication, value); } + public List Workspaces + { + get => (GetArray(KeyWorkspaces) ?? []).Select(n => (ConfigValuesWorkspace)n!.AsObject()).ToList(); + set => SetValue(KeyWorkspaces, value is not null ? new JsonArray(value.Select(w => (JsonObject)w).ToArray()) : default); + } + + public ConfigValuesWorkspace? GetWorkspace(string idOrName) + { + var workspaces = Workspaces.ToArray(); + return workspaces.FirstOrDefault(w => string.Equals(w.Id, idOrName, StringComparison.OrdinalIgnoreCase) || string.Equals(w.Name, idOrName, StringComparison.OrdinalIgnoreCase)); + } + + public ConfigValuesWorkspace GetRequiredWorkspace(string idOrName) => GetWorkspace(idOrName) ?? throw new FaluException($"Workspace '{idOrName}' not found. Check the spelling and casing and try again."); + public void RemoveUnknownKeys() { var keys = Inner.Select(n => n.Key).Except([ @@ -44,6 +59,7 @@ public void RemoveUnknownKeys() KeyDefaultWorkspaceId, KeyDefaultLiveMode, KeyAuthentication, + KeyWorkspaces, ]).ToArray(); foreach (var key in keys) Inner.Remove(key); @@ -85,6 +101,32 @@ public ConfigValuesAuthenticationTokens(OidcTokenResponse response) : base([]) public static implicit operator ConfigValuesAuthenticationTokens?(JsonObject? inner) => inner is null ? null : new(inner); } +internal sealed class ConfigValuesWorkspace : AbstractConfigValues +{ + private const string KeyId = "id"; + private const string KeyName = "name"; + private const string KeyStatus = "status"; + + public ConfigValuesWorkspace(JsonObject inner) : base(inner) { } + + public ConfigValuesWorkspace(Client.Workspaces.Workspace workspace) : base([]) + { + Id = workspace.Id!; + Name = workspace.Name!; + Status = workspace.Status!; + } + + public string Id { get => GetRequiredValue(KeyId); set => SetValue(KeyId, value); } + public string Name { get => GetRequiredValue(KeyName); set => SetValue(KeyName, value); } + public string Status { get => GetRequiredValue(KeyStatus); set => SetValue(KeyStatus, value); } + + [return: NotNullIfNotNull(nameof(workspace))] + public static implicit operator JsonObject?(ConfigValuesWorkspace? workspace) => workspace?.Inner; + + [return: NotNullIfNotNull(nameof(inner))] + public static implicit operator ConfigValuesWorkspace?(JsonObject? inner) => inner is null ? null : new(inner); +} + internal abstract class AbstractConfigValues(JsonObject inner) { protected JsonObject Inner { get; } = inner; @@ -95,7 +137,11 @@ internal abstract class AbstractConfigValues(JsonObject inner) protected T GetPrimitiveValue(string key, T defaultValue) where T : struct => GetPrimitiveValue(key) ?? defaultValue; protected T GetValue(string key, T defaultValue) => GetValue(key) ?? defaultValue; + protected T GetRequiredPrimitiveValue(string key) where T : struct => GetPrimitiveValue(key) ?? throw new InvalidOperationException($"The required value '{key}' is missing."); + protected T GetRequiredValue(string key) => GetValue(key) ?? throw new InvalidOperationException($"The required value '{key}' is missing."); + protected JsonObject? GetObject(string key) => Inner.TryGetPropertyValue(key, out var node) && node is JsonObject jo ? jo : null; + protected JsonArray? GetArray(string key) => Inner.TryGetPropertyValue(key, out var node) && node is JsonArray ja ? ja : null; protected void SetValue(string key, JsonNode? node) { diff --git a/src/FaluCli/FaluCliJsonSerializerContext.cs b/src/FaluCli/FaluCliJsonSerializerContext.cs index c70b69f8..da33b05f 100644 --- a/src/FaluCli/FaluCliJsonSerializerContext.cs +++ b/src/FaluCli/FaluCliJsonSerializerContext.cs @@ -3,6 +3,8 @@ namespace Falu; +[JsonSerializable(typeof(List))] + [JsonSerializable(typeof(Events.WebhookEvent))] [JsonSerializable(typeof(Client.Events.EventDeliveryRetry))] [JsonSerializable(typeof(Client.Events.WebhookDeliveryAttempt))] diff --git a/src/FaluCli/Program.cs b/src/FaluCli/Program.cs index ec9619a5..26bc7756 100644 --- a/src/FaluCli/Program.cs +++ b/src/FaluCli/Program.cs @@ -1,6 +1,7 @@ using Azure.Monitor.OpenTelemetry.Exporter; using Falu; using Falu.Client; +using Falu.Commands; using Falu.Commands.Config; using Falu.Commands.Events; using Falu.Commands.Login; @@ -18,6 +19,7 @@ { new LoginCommand(), new LogoutCommand(), + new WorkspacesCommand(), new EventsCommand(), diff --git a/src/FaluCli/WorkspacedCommand.cs b/src/FaluCli/WorkspacedCommand.cs index edb5caf8..0383a151 100644 --- a/src/FaluCli/WorkspacedCommand.cs +++ b/src/FaluCli/WorkspacedCommand.cs @@ -12,7 +12,7 @@ public WorkspacedCommand(string name, string? description = null) : base(name, d this.AddOption(aliases: ["--workspace"], description: Res.OptionDescriptionWorkspace, - format: Constants.WorkspaceIdFormat); + format: Constants.WorkspaceIdFormat); // TODO: allow name here and change the validation to use configValues hence update readers of the option // without this the nullable type, the option is not found because we have not migrated to the new bindings this.AddOption(aliases: ["--live"],