diff --git a/README.md b/README.md index d11ba5b..8d6da9f 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ Configure the connection to your Timetracker instance. | `--show` | | no | Display current config (token masked) | | `--reset` | | no | Delete all local config and activity cache | -*Required when setting up or updating credentials. +*Required only on first-time setup. + +Configuration updates are **non-destructive**: once configured, each option can be changed on its own and everything else is preserved. Only `--url`/`--token` trigger a re-authentication (and refresh of the activity cache). ```bash # Initial setup @@ -81,7 +83,10 @@ timetracker config -u https://acme.timehub.7pace.com -t eyJ... # View current config timetracker config --show -# Change the list table border (keeps credentials) +# Rotate just the token (URL and other settings are kept) +timetracker config -t eyJnew... + +# Change the list table border (no network call, credentials untouched) timetracker config --border square # Remove all local config diff --git a/Timetracker.Console/Program.cs b/Timetracker.Console/Program.cs index 339b5e0..d4bf494 100644 --- a/Timetracker.Console/Program.cs +++ b/Timetracker.Console/Program.cs @@ -119,26 +119,30 @@ async Task ConfigAction(ConfigOptions opts, CancellationToken cancellationT return 1; } - // Border-only update: preserve credentials, just change the border. - if (!string.IsNullOrEmpty(opts.Border) - && string.IsNullOrEmpty(opts.TimetrackerUrl) - && string.IsNullOrEmpty(opts.TimetrackerBearerToken)) + // Only re-authenticate when credentials are actually being set or changed. + var settingCredentials = !string.IsNullOrEmpty(opts.TimetrackerUrl) + || !string.IsNullOrEmpty(opts.TimetrackerBearerToken); + + if (!settingCredentials) { - if (!ConfigService.ConfigExists()) - { - ConsoleHelper.WriteError(ConsoleHelper.ConfigNotFound); - return 1; - } + // Partial update (e.g. only --border): merge into the existing config, no network call. + ConfigService.SaveConfig(opts); + + ConsoleHelper.WriteSuccess(!string.IsNullOrEmpty(opts.Border) + ? $"Table border set to '{opts.Border.ToLowerInvariant()}'." + : "Configuration updated."); - var border = opts.Border.ToLowerInvariant(); - ConfigService.SetTableBorder(border); - ConsoleHelper.WriteSuccess($"Table border set to '{border}'."); return 0; } + // Authenticate with the effective credentials (newly provided ones fall back to what is stored). + var current = ConfigService.ConfigExists() ? ConfigService.LoadConfig() : null; + var url = opts.TimetrackerUrl ?? current?.TimetrackerUrl; + var token = opts.TimetrackerBearerToken ?? current?.TimetrackerBearerToken; + Console.WriteLine("Authenticating with Timetracker..."); - var user = await HttpService.GetTimetrackerUser(opts.TimetrackerUrl, opts.TimetrackerBearerToken, cancellationToken); + var user = await HttpService.GetTimetrackerUser(url, token, cancellationToken); ConfigService.SaveConfig( opts, diff --git a/Timetracker.Console/Services/ConfigService.cs b/Timetracker.Console/Services/ConfigService.cs index 9694bf9..640f4e7 100644 --- a/Timetracker.Console/Services/ConfigService.cs +++ b/Timetracker.Console/Services/ConfigService.cs @@ -58,7 +58,30 @@ public static Config LoadConfig() return config; } - public static void SaveConfig(ConfigOptions opts, string userId, string displayName, string email, string accountName) + /// + /// Merges the provided options into the existing configuration: only values actually + /// supplied are overwritten, everything else is preserved. Safe for partial updates. + /// + public static void SaveConfig(ConfigOptions opts, string userId = null, string displayName = null, string email = null, string accountName = null) + { + // LoadConfig returns the token already decrypted; WriteConfig re-encrypts it. + var existing = ConfigExists() ? LoadConfig() : new Config(); + + var config = new Config + { + TimetrackerUrl = opts.TimetrackerUrl ?? existing.TimetrackerUrl, + TimetrackerBearerToken = opts.TimetrackerBearerToken ?? existing.TimetrackerBearerToken, + TimetrackerUserId = userId ?? existing.TimetrackerUserId, + DisplayName = displayName ?? existing.DisplayName, + Email = email ?? existing.Email, + AccountName = accountName ?? existing.AccountName, + TableBorder = opts.Border?.ToLowerInvariant() ?? existing.TableBorder + }; + + WriteConfig(config); + } + + private static void WriteConfig(Config config) { var configPath = GetConfigPath(); var folderPath = Path.GetDirectoryName(configPath); @@ -66,26 +89,8 @@ public static void SaveConfig(ConfigOptions opts, string userId, string displayN if (!Directory.Exists(folderPath)) Directory.CreateDirectory(folderPath); - string token = opts.TimetrackerBearerToken; - bool tokenEncrypted = false; - - if (OperatingSystem.IsWindows()) - { - token = EncryptToken(token); - tokenEncrypted = true; - } - - var config = new Config - { - TimetrackerUrl = opts.TimetrackerUrl, - TimetrackerBearerToken = token, - TimetrackerUserId = userId, - DisplayName = displayName, - Email = email, - AccountName = accountName, - TokenEncrypted = tokenEncrypted, - TableBorder = opts.Border ?? GetTableBorder() - }; + if (OperatingSystem.IsWindows() && !string.IsNullOrEmpty(config.TimetrackerBearerToken)) + config = config with { TimetrackerBearerToken = EncryptToken(config.TimetrackerBearerToken), TokenEncrypted = true }; File.WriteAllText(configPath, JsonConvert.SerializeObject(config, Formatting.Indented)); @@ -110,19 +115,6 @@ public static string GetTableBorder() } } - /// Updates only the table border, preserving the (encrypted) token and everything else. - public static void SetTableBorder(string border) - { - var configPath = GetConfigPath(); - if (!File.Exists(configPath)) - throw new FileNotFoundException($"{JSON_FILE_NAME} does not exist. Make sure you already executed the config method."); - - var config = JsonConvert.DeserializeObject(File.ReadAllText(configPath)) with { TableBorder = border }; - File.WriteAllText(configPath, JsonConvert.SerializeObject(config, Formatting.Indented)); - - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(configPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } [SupportedOSPlatform("windows")] private static string EncryptToken(string token) diff --git a/Timetracker.Console/Validators/ConfigValidator.cs b/Timetracker.Console/Validators/ConfigValidator.cs index fd6c8e2..f96f4de 100644 --- a/Timetracker.Console/Validators/ConfigValidator.cs +++ b/Timetracker.Console/Validators/ConfigValidator.cs @@ -1,5 +1,6 @@ -using FluentValidation; +using FluentValidation; using Timetracker.Options; +using Timetracker.Services; using Timetracker.Utils; namespace Timetracker.Validators; @@ -8,20 +9,29 @@ public class ConfigValidator : AbstractValidator { public ConfigValidator() { - // A border-only invocation (config --border ...) does not set credentials. - bool BorderOnly(ConfigOptions x) => - !string.IsNullOrEmpty(x.Border) && - string.IsNullOrEmpty(x.TimetrackerUrl) && - string.IsNullOrEmpty(x.TimetrackerBearerToken); + RuleFor(x => x) + .Must(x => x.Show || x.Reset + || !string.IsNullOrEmpty(x.TimetrackerUrl) + || !string.IsNullOrEmpty(x.TimetrackerBearerToken) + || !string.IsNullOrEmpty(x.Border)) + .WithMessage("Provide at least one option: --url, --token, --border, --show, or --reset."); - When(x => !x.Show && !x.Reset && !BorderOnly(x), () => + // Credentials are only mandatory on first-time setup. Once configured, any + // option can be updated on its own without re-entering the others. + When(x => !x.Show && !x.Reset && !ConfigService.ConfigExists(), () => { RuleFor(x => x.TimetrackerBearerToken) .NotEmpty().WithMessage("A Bearer token is required for authentication."); RuleFor(x => x.TimetrackerUrl) - .NotEmpty().WithMessage("The Timetracker URL is required. Please provide the base URL.") - .Must(ValidationUtils.ValidUrl).WithMessage("The provided URL is invalid or does not use HTTPS. Ensure it is in the format 'https://.timehub.7pace.com'."); + .NotEmpty().WithMessage("The Timetracker URL is required. Please provide the base URL."); + }); + + When(x => !string.IsNullOrEmpty(x.TimetrackerUrl), () => + { + RuleFor(x => x.TimetrackerUrl) + .Must(ValidationUtils.ValidUrl) + .WithMessage("The provided URL is invalid or does not use HTTPS. Ensure it is in the format 'https://.timehub.7pace.com'."); }); When(x => !string.IsNullOrEmpty(x.Border), () =>