Phase 1: accounts foundation (model, vault, store, CLI) #25

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-04 20:34:38 +00:00
15 changed files with 1204 additions and 38 deletions

No files matched your search

+86 -1
View File
@@ -27,7 +27,9 @@ and runs on Linux/macOS hosts with the .NET 10 SDK.
gclo sync --org <name> --target <folder> [--parallel N] [--sanitize-paths]
[--token-env VAR | --token-file PATH | --token-stdin]
[--json] [--quiet]
gclo sync --account <name> [same options — each one overrides the account value]
gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo accounts [--json]
gclo --version
gclo --help (each command also accepts --help)
```
@@ -128,6 +130,73 @@ A token that cannot list organizations (a fine-grained PAT, or a classic PAT
without the `read:org` scope) still prints its own account login — you can pass
any organization name to `gclo sync` manually.
## Accounts
An *account* is a saved sync profile: a name, an organization, a target root
folder, a parallelism setting, an org-subfolder preference, and a token stored
in **Windows Credential Manager** (under `gclo:account:<id>`; the metadata file,
`%LOCALAPPDATA%\gclo\accounts.json`, never contains the token). Accounts are
created and edited in the gclo desktop app's account wizard for now — a CLI
flag for creating accounts is planned.
> **Windows-only.** Because the token lives in Windows Credential Manager,
> `gclo accounts` and `gclo sync --account` exit with code 2 on Linux/macOS.
> Plain `gclo sync` and `gclo orgs` work everywhere.
### `gclo accounts`
Lists the saved accounts, one per line, in aligned columns: name, organization,
target root, and last sync time (local time, `never` when the account has not
completed a sync yet):
```
work contoso C:\src\contoso 2026-07-04 09:12
personal octocat D:\mirror never
```
With `--json` it prints a single-line JSON array instead; `lastSync` is the UTC
timestamp of the last completed sync, or `null`:
```json
[{"name":"work","organization":"contoso","targetRoot":"C:\\src\\contoso","lastSync":"2026-07-04T14:12:03+00:00"},{"name":"personal","organization":"octocat","targetRoot":"D:\\mirror","lastSync":null}]
```
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
hint goes to stderr; the exit code is still 0.
### `gclo sync --account <name>`
Runs a sync with the account's settings and its stored token — no `--org`,
`--target`, or token option needed:
```powershell
gclo sync --account work
```
Account values are **defaults**; any explicit option overrides them:
| Setting | Account value | Overridden by |
| --- | --- | --- |
| Organization | the account's organization | `--org` |
| Target folder | the account's target root — plus an `\<organization>` subfolder when the account opts into one | `--target` (used verbatim; no subfolder is appended) |
| Parallelism | the account's max concurrency | `--parallel` |
| Token | the Windows Credential Manager entry | `--token-env`, `--token-file`, or `--token-stdin` |
Notes:
- When the account opts into an organization subfolder and you override
`--org`, the subfolder follows the *effective* organization:
`<targetRoot>\<org>`.
- When the run completes (exit code 0 or 1, including a canceled run), the
time and the summary line are recorded on the account — `gclo accounts` and
the desktop app show them as the last sync.
- An unknown account name exits with code 2 and lists the available account
names on stderr.
- An account whose Credential Manager entry is missing (deleted, or the
profile moved to another machine — the entry does not roam) exits with
code 2. Re-enter the token in the desktop app's account wizard, restore the
`gclo:account:<id>` credential manually, or pass a token option for this run.
## Providing the token
gclo needs a GitHub Personal Access Token for both the API and the git transport.
@@ -149,6 +218,10 @@ gclo needs a GitHub Personal Access Token for both the API and the git transport
The options are mutually exclusive. A missing or empty token prints an error to
stderr and exits with code 2.
With `gclo sync --account`, the default source is the account's token in
Windows Credential Manager instead of `GITHUB_TOKEN`; any token option above
still wins for that run (the stored token is left untouched).
## Activity log
Every invocation appends to a daily activity log file, `gclo-yyyy-MM-dd.log`,
@@ -164,7 +237,7 @@ else on disk; see [SECURITY.md](../SECURITY.md).
| --- | --- |
| 0 | Everything succeeded. |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
| 2 | Fatal: bad arguments, missing/empty/rejected token, or organization not found. |
| 2 | Fatal: bad arguments, missing/empty/rejected token, organization not found, unknown account, missing account token, or an account command on a non-Windows OS. |
A repository recovered by `--sanitize-paths` counts as a success: if every other
repository also succeeds, the exit code is `0`.
@@ -199,6 +272,18 @@ if ($LASTEXITCODE -ne 0) { Write-Error "sync ended with code $LASTEXITCODE" }
# Legacy repos with Windows-invalid paths: rename/skip them instead of failing
gclo sync --org contoso --target C:\src\contoso --sanitize-paths
# Saved accounts (created in the desktop app; Windows only)
gclo accounts # name, organization, target root, last sync
gclo sync --account work # settings and token come from the account
gclo sync --account work --parallel 16 # explicit options override account values
gclo sync --account work --org other-org # same target root, different organization
# Nightly job over every account
gclo accounts --json | ConvertFrom-Json | ForEach-Object {
gclo sync --account $_.name --quiet
if ($LASTEXITCODE -ne 0) { Write-Error "sync of '$($_.name)' ended with code $LASTEXITCODE" }
}
```
### bash
+14 -3
View File
@@ -47,9 +47,14 @@ New in `gclo.ViewModels` (app-layer library, headless-testable):
`MaxConcurrency`, `LastSyncUtc`, `LastSyncSummary` (counts string).
Metadata JSON at `%LOCALAPPDATA%\gclo\accounts.json` — never contains tokens.
- `ITokenVault` { `Store(accountId, token)`, `TryRetrieve(accountId)`,
`Delete(accountId)` } — implementation `CredentialManagerVault` using
`Windows.Security.Credentials.PasswordVault`, resource name
`gclo:account:<id>`; `InMemoryVault` fake for tests.
`Delete(accountId)` } — implementation `CredentialManagerVault` using Win32
`CredWrite/CredRead/CredDelete` P/Invoke (generic credentials, target name
`gclo:account:<id>`); `InMemoryVault` fake for tests. **Decision changed from
PasswordVault after the spike**: the WinRT API would force Windows-only TFMs
onto gclo.ViewModels and the cross-platform CLI, while the Win32 store works
from plain net10.0 (spike-verified: write/read/delete round-trip without
package identity) and surfaces tokens in Credential Manager's "Windows
Credentials" UI where the owner can inspect them.
- `AccountsStore`: CRUD + rename-safe persistence (same never-throw discipline
as `AppSettings`); deleting an account deletes its vault entry.
- CLI (`gclo.Cli`): `gclo accounts` (list: name, org, target, last sync);
@@ -94,6 +99,12 @@ Inside `WorkspacePage`, replacing the stacked form:
progress-honesty P2).
3. **Results InfoBar** (severity-aware): summary + inline Retry failed / Open
folder; replaces permanent buttons and the stale Retry visibility bug.
3b. **In-progress visibility** (owner decision on #16): a pinned **active
strip** docked between toolbar and table showing exactly the repos currently
Cloning/Pulling with per-repo progress — bounded by the parallelism setting,
so it never scrolls and the table never moves under the user. Plus **status
filter chips** on the toolbar (All · Active · Failed · Pending); Failed
doubles as retry triage. Rejected: viewport auto-scroll and live re-sorting.
4. **Designed empty states**: no token → connect card (+ "how to create a
token" link, fixing that P2); connected → "Load N repositories from {org}";
filter-no-match state.
+130
View File
@@ -0,0 +1,130 @@
using System.Globalization;
using System.Text.Json;
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli;
/// <summary>'gclo accounts': list the saved accounts and their last sync outcome.</summary>
internal static class AccountsCommand
{
private const string HelpText = """
Usage: gclo accounts [options]
Lists the saved accounts, one per line, in aligned columns:
name, organization, target root, and last sync time (local time,
'never' when the account has not completed a sync yet).
An account pairs sync settings (organization, target root, parallelism,
org-subfolder preference) with a token stored in Windows Credential
Manager. Use one with 'gclo sync --account <name>'. Accounts are created
and edited in the gclo desktop app for now; a CLI flag for creating them
is planned. Because the token lives in Windows Credential Manager,
accounts only work on Windows.
Options:
--json Print a single-line JSON array instead:
[{"name":"...","organization":"...","targetRoot":"...",
"lastSync":"2026-07-04T15:30:00+00:00" or null}]
--help Show this help.
Exit codes:
0 listed successfully (also when no accounts exist yet)
2 fatal: bad arguments, or not running on Windows
""";
public static int Run(string[] args)
{
bool json = false;
var reader = new OptionReader(args);
while (reader.MoveNext())
{
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return 0;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts'.");
}
}
// One activity log per invocation; tokens are never read here, let alone logged.
var log = new FileActivityLog();
log.Info($"accounts started: json={json}");
try
{
(AccountsStore store, _) = Open(log);
IReadOnlyList<Account> accounts = store.GetAll();
log.Info($"accounts finished: {accounts.Count} account(s) listed.");
if (json)
{
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(a.Name, a.Organization, a.TargetRoot, a.LastSyncUtc))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
return 0;
}
if (accounts.Count == 0)
{
// Diagnostics go to stderr so redirected stdout stays clean (and empty).
Console.Error.WriteLine("No accounts yet. Create one in the gclo desktop app.");
return 0;
}
int nameWidth = accounts.Max(a => a.Name.Length);
int orgWidth = accounts.Max(a => a.Organization.Length);
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
foreach (Account account in accounts)
{
Console.Out.WriteLine(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
}
return 0;
}
catch (Exception ex)
{
// Fatal path: unusable accounts store or non-Windows platform.
// Program prints the message; the log keeps it.
log.Error($"accounts failed: {ex.Message}", ex);
throw;
}
}
/// <summary>
/// Opens the accounts store backed by Windows Credential Manager. The vault is
/// returned alongside the store because token retrieval for 'gclo sync --account'
/// goes through the vault directly (the store only handles metadata).
/// Constructed lazily — only the account code paths call this — so plain
/// 'gclo sync' and 'gclo orgs' never touch the credential store.
/// </summary>
/// <exception cref="CliErrorException">The current OS is not Windows.</exception>
internal static (AccountsStore Store, ITokenVault Vault) Open(IActivityLog log)
{
if (!OperatingSystem.IsWindows())
{
throw new CliErrorException(
"Accounts require Windows credential storage; "
+ "'gclo accounts' and 'gclo sync --account' only work on Windows.");
}
ITokenVault vault = new CredentialManagerVault();
return (new AccountsStore(vault, log: log), vault);
}
/// <summary>'never', or the local time of the last completed sync at minute precision.</summary>
private static string FormatLastSync(DateTimeOffset? lastSyncUtc)
=> lastSyncUtc is null
? "never"
: lastSyncUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
+12
View File
@@ -15,6 +15,17 @@ internal sealed record SyncJsonResult(
bool WasCanceled,
IReadOnlyList<SyncFailure> Failures);
/// <summary>
/// One saved account in the JSON listing of 'gclo accounts --json'.
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
/// when the account has never synced.
/// </summary>
internal sealed record AccountSummary(
string Name,
string Organization,
string TargetRoot,
DateTimeOffset? LastSync);
/// <summary>
/// Source-generated System.Text.Json serialization: no runtime reflection, so the
/// project stays free of trim/AOT warnings.
@@ -22,4 +33,5 @@ internal sealed record SyncJsonResult(
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SyncJsonResult))]
[JsonSerializable(typeof(IReadOnlyList<string>))]
[JsonSerializable(typeof(IReadOnlyList<AccountSummary>))]
internal sealed partial class CliJsonContext : JsonSerializerContext;
+15 -5
View File
@@ -10,16 +10,22 @@ internal static class Program
Usage:
gclo sync --org <name> --target <folder> [options]
gclo sync --account <name> [options]
gclo orgs [options]
gclo accounts [--json]
gclo --version
gclo --help
Commands:
sync Clone every repository of an organization; fast-forward ones that
already exist locally.
orgs List the account and organization logins the token can see.
sync Clone every repository of an organization; fast-forward ones
that already exist locally. '--account <name>' runs it with a
saved account's settings and stored token.
orgs List the account and organization logins the token can see.
accounts List the saved accounts (name, organization, target root, last
sync). Accounts are created in the gclo desktop app and are
Windows-only.
Run 'gclo sync --help' or 'gclo orgs --help' for command options.
Run 'gclo <command> --help' (e.g. 'gclo sync --help') for command options.
Token:
There is deliberately no '--token <value>' option: command-line arguments
@@ -29,7 +35,9 @@ internal static class Program
--token-file <path> read the first non-blank line of a file
--token-stdin read one line from standard input
(pipe it from a secret store)
When no token option is given, the GITHUB_TOKEN environment variable is used.
When no token option is given, the GITHUB_TOKEN environment variable is
used — except with 'gclo sync --account', which reads the account's token
from Windows Credential Manager instead. A token option always wins.
""";
public static async Task<int> Main(string[] args)
@@ -90,6 +98,8 @@ internal static class Program
return SyncCommand.RunAsync(rest, cancellationToken);
case "orgs":
return OrgsCommand.RunAsync(rest, cancellationToken);
case "accounts":
return Task.FromResult(AccountsCommand.Run(rest));
case "--help" or "-h" or "help":
Console.Out.WriteLine(RootHelp);
return Task.FromResult(0);
+123 -25
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli;
@@ -8,15 +9,27 @@ internal static class SyncCommand
{
private const string HelpText = """
Usage: gclo sync --org <name> --target <folder> [options]
gclo sync --account <name> [options]
Clones every repository of <name> into <folder>\<repo>. Repositories that
already exist locally are fetched and fast-forwarded instead. Repositories
fail independently; one failure never stops the rest.
Options:
--org <name> GitHub organization or user account to sync (required).
--org <name> GitHub organization or user account to sync
(required unless --account provides it).
--target <folder> Local root folder; each repository becomes a subfolder
(required; created if missing).
(required unless --account provides it; created if
missing).
--account <name> Use a saved account (see 'gclo accounts'; Windows
only): seeds --org, --target, and --parallel from
the account and reads the token from Windows
Credential Manager. When the account opts into an
organization subfolder the target becomes
<targetRoot>\<org>. Explicit options override the
account's values; a token option overrides the
stored token. On completion the run's time and
summary are recorded on the account.
--parallel <N> Maximum simultaneous git operations (default 8).
--sanitize-paths When a repository contains paths that are legal in
git but invalid on Windows (reserved device names,
@@ -53,7 +66,8 @@ internal static class SyncCommand
Exit codes:
0 every repository synced
1 run completed but some repositories failed, or it was canceled
2 fatal: bad arguments, missing or rejected token, organization not found
2 fatal: bad arguments, missing or rejected token, organization not
found, or an unknown account or missing account token
Security:
There is deliberately no '--token <value>' option: process command lines
@@ -65,7 +79,8 @@ internal static class SyncCommand
{
string? org = null;
string? target = null;
int parallel = 8;
string? accountName = null;
int? parallel = null;
bool json = false;
bool quiet = false;
bool sanitizePaths = false;
@@ -89,13 +104,17 @@ internal static class SyncCommand
case "--target":
target = reader.RequireValue();
break;
case "--account":
accountName = reader.RequireValue();
break;
case "--parallel":
{
string value = reader.RequireValue();
if (!int.TryParse(value, out parallel) || parallel < 1)
if (!int.TryParse(value, out int parsed) || parsed < 1)
{
throw new CliUsageException($"--parallel expects a positive integer, got '{value}'.");
}
parallel = parsed;
break;
}
case "--json":
@@ -115,29 +134,46 @@ internal static class SyncCommand
}
}
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
if (string.IsNullOrWhiteSpace(target))
{
throw new CliUsageException("--target is required.");
}
// One activity log per invocation. Only non-secret parameters are ever
// written to it; the token is not.
var log = new FileActivityLog();
log.Info($"sync started: org='{org}', target='{target}', parallel={parallel}, "
+ $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}");
try
{
string token = tokenOptions.Resolve();
// --account: seed org/target/parallel from the saved account. Explicit
// options given alongside win because ??= only fills what is still unset.
Account? account = null;
AccountsStore? store = null;
ITokenVault? vault = null;
if (accountName is not null)
{
(store, vault) = AccountsCommand.Open(log);
account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
org ??= account.Organization;
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
parallel ??= account.MaxConcurrency;
}
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required (or use --account).");
}
if (string.IsNullOrWhiteSpace(target))
{
throw new CliUsageException("--target is required (or use --account).");
}
int effectiveParallel = parallel ?? AppSettings.DefaultConcurrency;
log.Info($"sync started: org='{org}', target='{target}', parallel={effectiveParallel}, "
+ $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}"
+ (account is null ? "" : $", account='{account.Name}'"));
string token = ResolveToken(tokenOptions, account, vault);
var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json);
var git = new LibGit2GitClient();
var engine = new OrgSyncEngine(new GitHubRepositoryLister(), git);
var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel);
var request = new SyncRequest(org.Trim(), token, target.Trim(), effectiveParallel);
var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths);
SyncSummary summary;
@@ -185,6 +221,13 @@ internal static class SyncCommand
+ $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}"
+ (sanitizePaths ? $", sanitizedPaths={sanitized}" : ""));
string verb = summary.WasCanceled ? "Canceled" : "Finished";
string clonedText = sanitized > 0
? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)"
: $"{summary.Cloned} cloned";
string summaryLine = $"{verb}: {clonedText}, {summary.Updated} updated, "
+ $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}.";
if (json)
{
var result = new SyncJsonResult(
@@ -194,13 +237,14 @@ internal static class SyncCommand
}
else
{
string verb = summary.WasCanceled ? "Canceled" : "Finished";
string clonedText = sanitized > 0
? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)"
: $"{summary.Cloned} cloned";
Console.Out.WriteLine(
$"{verb}: {clonedText}, {summary.Updated} updated, "
+ $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}.");
Console.Out.WriteLine(summaryLine);
}
if (account is not null)
{
// The run completed (exit 0 or 1): remember when and how it went,
// so 'gclo accounts' and the desktop app can show it.
TryRecordSyncResult(store!, account, summaryLine, log);
}
return summary.Failed > 0 || summary.WasCanceled ? 1 : 0;
@@ -214,6 +258,60 @@ internal static class SyncCommand
}
}
/// <summary>
/// The token for this run. Any explicit token option wins; otherwise a saved
/// account's token comes from Windows Credential Manager; plain runs fall back
/// to the default environment variable.
/// </summary>
private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault)
{
if (account is null || tokenOptions.HasExplicitSource)
{
return tokenOptions.Resolve();
}
// vault is non-null whenever account is: both come from AccountsCommand.Open.
string? token = vault!.TryRetrieve(account.Id);
if (string.IsNullOrEmpty(token))
{
throw new CliErrorException(
$"Account '{account.Name}' has no token in Windows Credential Manager "
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token in the gclo "
+ "desktop app's account wizard, restore the credential entry manually, "
+ "or pass a token with --token-env, --token-file, or --token-stdin.");
}
return token;
}
/// <summary>
/// Records the completed run's time and summary on the account. A persistence
/// failure must not change the sync's exit code — the repositories are already
/// on disk — so it is reported as a warning instead of propagating.
/// </summary>
private static void TryRecordSyncResult(AccountsStore store, Account account, string summaryLine, IActivityLog log)
{
try
{
store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, summaryLine);
}
catch (Exception ex)
{
log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex);
Console.Error.WriteLine(
$"Warning: could not record the sync result on account '{account.Name}': {ex.Message}");
}
}
/// <summary>Unknown --account name: tell the user what does exist (exit code 2).</summary>
private static CliErrorException UnknownAccount(string name, AccountsStore store)
{
IReadOnlyList<Account> all = store.GetAll();
string available = all.Count == 0
? "No accounts exist yet; create one in the gclo desktop app."
: "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + ".";
return new CliErrorException($"No account named '{name}'. {available}");
}
/// <summary>
/// Applies an automatic <see cref="PathRecovery"/> (rename to the suggested name,
/// skip what has none) to one repository that failed with Windows-invalid paths.
+42 -4
View File
@@ -17,6 +17,13 @@ internal sealed class TokenOptions
private string? _filePath;
private bool _useStdin;
/// <summary>
/// True when any token option (--token-env, --token-file, --token-stdin) was
/// given. 'gclo sync --account' uses this to let an explicit token source
/// override the account's vault-stored token.
/// </summary>
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
/// <summary>Consumes the reader's current option when it is a token option.</summary>
public bool TryConsume(OptionReader reader)
{
@@ -98,18 +105,49 @@ internal sealed class TokenOptions
private static string FromStdin()
{
if (!Console.IsInputRedirected)
string? line;
if (Console.IsInputRedirected)
{
// Interactive use: make it obvious the process is waiting. The prompt
// goes to stderr so redirected stdout stays clean.
line = Console.In.ReadLine()?.Trim();
}
else
{
// Interactive use: prompt on stderr (redirected stdout stays clean) and
// read WITHOUT echo, like git/gh credential prompts — an echoed token
// would sit on screen and in the terminal scrollback.
Console.Error.Write("Token: ");
line = ReadLineNoEcho().Trim();
Console.Error.WriteLine();
}
string? line = Console.In.ReadLine()?.Trim();
if (string.IsNullOrEmpty(line))
{
throw new CliErrorException("No token arrived on standard input (expected one line).");
}
return line;
}
private static string ReadLineNoEcho()
{
var buffer = new System.Text.StringBuilder();
while (true)
{
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter)
{
return buffer.ToString();
}
if (key.Key == ConsoleKey.Backspace)
{
if (buffer.Length > 0)
{
buffer.Length--;
}
}
else if (key.KeyChar != '\0')
{
buffer.Append(key.KeyChar);
}
}
}
}
+1
View File
@@ -12,6 +12,7 @@
<ItemGroup>
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
</ItemGroup>
</Project>
+221
View File
@@ -0,0 +1,221 @@
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="AccountsStore"/>: JSON round-trips across store instances,
/// case-insensitive lookups and name uniqueness, vault coordination, and tolerance
/// of a corrupt accounts file. Every store uses an <see cref="InMemoryVault"/> and a
/// unique temp directory.
/// </summary>
public sealed class AccountsStoreTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountsStore NewStore() => new(_vault, _root);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
// ---------------------------------------------------------------- persistence
[Fact]
public void Crud_RoundTrips_AcrossStoreInstances()
{
var account = MakeAccount("Work") with
{
Description = "primary org",
CreateOrgSubfolder = true,
MaxConcurrency = 4,
};
NewStore().Save(account, token: null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
var edited = account with { Name = "Work (new)", Organization = "acme-2" };
NewStore().Save(edited, token: null);
Assert.Equal(edited, Assert.Single(NewStore().GetAll()));
NewStore().Delete(account.Id);
Assert.Empty(NewStore().GetAll());
}
[Fact]
public void FreshDirectory_YieldsEmptyList()
{
Assert.Empty(NewStore().GetAll());
}
// ---------------------------------------------------------------- queries
[Fact]
public void GetAll_SortsByName_CaseInsensitively()
{
var store = NewStore();
store.Save(MakeAccount("Charlie"), null);
store.Save(MakeAccount("beta"), null);
store.Save(MakeAccount("Alpha"), null);
// Ordinal (case-sensitive) order would be Alpha, Charlie, beta.
Assert.Equal(
new[] { "Alpha", "beta", "Charlie" },
store.GetAll().Select(a => a.Name));
}
[Fact]
public void FindByName_MatchesCaseInsensitively()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, null);
Assert.Equal(account, store.FindByName("WORK"));
Assert.Equal(account, store.FindByName("work"));
Assert.Null(store.FindByName("missing"));
}
// ---------------------------------------------------------------- name uniqueness
[Theory]
[InlineData("Work")]
[InlineData("WORK")]
[InlineData("work")]
public void Save_DuplicateNameOnDifferentId_ThrowsAndPersistsNothing(string duplicateName)
{
NewStore().Save(MakeAccount("Work"), null);
var duplicate = MakeAccount(duplicateName);
Assert.Throws<ArgumentException>(() => NewStore().Save(duplicate, "secret"));
var survivor = Assert.Single(NewStore().GetAll());
Assert.Equal("Work", survivor.Name);
Assert.Null(_vault.TryRetrieve(duplicate.Id));
}
[Fact]
public void Save_SameIdWithSameName_IsAnUpdateNotADuplicate()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, null);
store.Save(account with { Description = "edited" }, null);
Assert.Equal("edited", Assert.Single(store.GetAll()).Description);
}
// ---------------------------------------------------------------- vault coordination
[Fact]
public void Save_WithToken_StoresItInTheVault()
{
var account = MakeAccount("Work");
NewStore().Save(account, "ghp_secret");
Assert.Equal("ghp_secret", _vault.TryRetrieve(account.Id));
}
[Fact]
public void Save_WithNullToken_LeavesVaultUntouched()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, "original");
store.Save(account with { Description = "edited" }, null);
Assert.Equal("original", _vault.TryRetrieve(account.Id));
}
[Fact]
public void Delete_RemovesMetadataAndVaultEntry_AndIsIdempotent()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, "secret");
store.Delete(account.Id);
Assert.Empty(store.GetAll());
Assert.Empty(NewStore().GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
store.Delete(account.Id); // deleting again must not throw
}
// ---------------------------------------------------------------- sync results
[Fact]
public void RecordSyncResult_UpdatesOnlySyncFields_AndPersists()
{
var account = MakeAccount("Work") with { Description = "keep me", MaxConcurrency = 4 };
NewStore().Save(account, null);
var finished = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero);
NewStore().RecordSyncResult(account.Id, finished, "12 cloned, 3 updated, 0 failed");
var updated = Assert.Single(NewStore().GetAll());
Assert.Equal(
account with { LastSyncUtc = finished, LastSyncSummary = "12 cloned, 3 updated, 0 failed" },
updated);
}
// ---------------------------------------------------------------- load tolerance
[Fact]
public void CorruptFile_YieldsEmptyList_AndSubsequentSaveWorks()
{
Directory.CreateDirectory(_root);
File.WriteAllText(Path.Combine(_root, "accounts.json"), "{ this is not json !!");
var store = NewStore();
Assert.Empty(store.GetAll());
var account = MakeAccount("Recovered");
store.Save(account, null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
}
[Fact]
public void Load_CorruptFile_IsPreservedAside_SoSaveCannotClobberIt()
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, "accounts.json");
File.WriteAllText(path, "{ not valid json");
var store = NewStore();
Assert.Empty(store.GetAll());
// The unreadable original must survive as evidence for token re-association.
string? preserved = Directory.GetFiles(_root, "accounts.json.corrupt-*").SingleOrDefault();
Assert.NotNull(preserved);
Assert.Equal("{ not valid json", File.ReadAllText(preserved));
store.Save(MakeAccount("Fresh"), null);
Assert.Equal("{ not valid json", File.ReadAllText(preserved)); // untouched
}
[Fact]
public void Persist_ReplacesAtomically_KeepingABackupOfThePreviousFile()
{
var store = NewStore();
store.Save(MakeAccount("First"), null);
store.Save(MakeAccount("Second"), null);
// The atomic File.Replace keeps the prior generation as .bak, and no
// temp file is left behind.
Assert.True(File.Exists(Path.Combine(_root, "accounts.json.bak")));
Assert.False(File.Exists(Path.Combine(_root, "accounts.json.tmp")));
Assert.Equal(2, NewStore().GetAll().Count);
}
}
+87
View File
@@ -0,0 +1,87 @@
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Contract tests for <see cref="InMemoryVault"/> plus one real round-trip through
/// <see cref="CredentialManagerVault"/> against the Windows Credential Manager.
/// </summary>
public sealed class VaultTests
{
// ---------------------------------------------------------------- InMemoryVault
[Fact]
public void InMemory_StoreThenRetrieve_ReturnsTheToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_secret");
Assert.Equal("ghp_secret", vault.TryRetrieve(id));
}
[Fact]
public void InMemory_Store_OverwritesTheExistingToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_old");
vault.Store(id, "ghp_new");
Assert.Equal("ghp_new", vault.TryRetrieve(id));
}
[Fact]
public void InMemory_TryRetrieve_AbsentEntry_ReturnsNull()
{
Assert.Null(new InMemoryVault().TryRetrieve(Guid.NewGuid()));
}
[Fact]
public void InMemory_Delete_RemovesTheToken_AndIsIdempotent()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_secret");
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id); // deleting an absent entry must not throw
}
// ---------------------------------------------------------------- CredentialManagerVault
[Fact]
public void CredentialManager_RoundTrips_AgainstTheRealStore()
{
// xunit 2.9 has no runtime skip, so on non-Windows this silently passes;
// CI runs windows-latest, where the body always executes.
if (!OperatingSystem.IsWindows())
{
return;
}
var vault = new CredentialManagerVault();
var id = Guid.NewGuid();
try
{
vault.Store(id, "ghp_round-trip-secret");
Assert.Equal("ghp_round-trip-secret", vault.TryRetrieve(id));
vault.Store(id, "ghp_rotated");
Assert.Equal("ghp_rotated", vault.TryRetrieve(id));
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id); // deleting an absent entry must not throw
}
finally
{
vault.Delete(id);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace gclo.ViewModels;
/// <summary>
/// A saved connection profile: which GitHub organization to sync, where its clones
/// live on disk, and how the sync runs. Metadata only — the access token is held
/// separately in an <see cref="ITokenVault"/> keyed by <see cref="Id"/> and is never
/// serialized alongside the account.
/// </summary>
public sealed record Account
{
/// <summary>Stable identity: keys the vault entry and survives renames.</summary>
public required Guid Id { get; init; }
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
public required string Name { get; init; }
/// <summary>Optional free-form note about what the account is for.</summary>
public string Description { get; init; } = "";
/// <summary>GitHub organization (or user) whose repositories are synced.</summary>
public required string Organization { get; init; }
/// <summary>Folder the sync targets; see <see cref="CreateOrgSubfolder"/>.</summary>
public required string TargetRoot { get; init; }
/// <summary>
/// When true the effective sync target is <see cref="TargetRoot"/>\<see cref="Organization"/>
/// rather than <see cref="TargetRoot"/> itself.
/// </summary>
public bool CreateOrgSubfolder { get; init; }
/// <summary>Parallel clone/pull count used when syncing this account.</summary>
public int MaxConcurrency { get; init; } = AppSettings.DefaultConcurrency;
/// <summary>When the account's last sync finished, or null if it never ran.</summary>
public DateTimeOffset? LastSyncUtc { get; init; }
/// <summary>One-line outcome of the last sync, or null if it never ran.</summary>
public string? LastSyncSummary { get; init; }
}
+236
View File
@@ -0,0 +1,236 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Persists account profiles as JSON at %LOCALAPPDATA%\gclo\accounts.json and their
/// tokens in an <see cref="ITokenVault"/>; the file holds metadata only and never a
/// token. Unlike <see cref="AppSettings"/>, accounts are primary user data, so write
/// failures in <see cref="Save"/>, <see cref="Delete"/>, and
/// <see cref="RecordSyncResult"/> propagate instead of being swallowed; only loading
/// is tolerant (a missing or corrupt file yields an empty list).
/// </summary>
public sealed class AccountsStore
{
private readonly ITokenVault _vault;
private readonly IActivityLog _log;
private readonly string _filePath;
private readonly object _gate = new();
private List<Account> _accounts;
/// <summary>
/// Loads existing accounts from <paramref name="directory"/> (default:
/// %LOCALAPPDATA%\gclo). A missing file simply means no accounts yet; a corrupt
/// or unreadable one is logged (pass the app's <paramref name="log"/> to surface
/// that) and treated as empty rather than blocking startup.
/// </summary>
public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
_log = log ?? new NullActivityLog();
directory ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
_filePath = Path.Combine(directory, "accounts.json");
_accounts = Load();
}
/// <summary>All accounts, sorted by <see cref="Account.Name"/> (case-insensitive).</summary>
public IReadOnlyList<Account> GetAll()
{
lock (_gate)
{
return _accounts.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
}
/// <summary>The account whose name matches case-insensitively, or null.</summary>
public Account? FindByName(string name)
{
lock (_gate)
{
return _accounts.FirstOrDefault(
a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase));
}
}
/// <summary>
/// Inserts or updates (matching by <see cref="Account.Id"/>) and persists
/// immediately. Throws <see cref="ArgumentException"/> when another account
/// already uses the name (case-insensitive); nothing is persisted in that case.
/// A non-null <paramref name="token"/> goes into the vault only after the
/// metadata write succeeds, so a vault entry never points at an unsaved account;
/// a null token leaves any existing vault entry untouched.
/// </summary>
public void Save(Account account, string? token)
{
ArgumentNullException.ThrowIfNull(account);
lock (_gate)
{
bool nameTaken = _accounts.Any(a =>
a.Id != account.Id
&& string.Equals(a.Name, account.Name, StringComparison.OrdinalIgnoreCase));
if (nameTaken)
{
throw new ArgumentException(
$"An account named '{account.Name}' already exists.", nameof(account));
}
var updated = new List<Account>(_accounts);
int index = updated.FindIndex(a => a.Id == account.Id);
if (index >= 0)
{
updated[index] = account;
}
else
{
updated.Add(account);
}
Persist(updated); // IO failures propagate; _accounts stays unchanged then.
_accounts = updated;
if (token is not null)
{
_vault.Store(account.Id, token);
}
}
}
/// <summary>
/// Removes the account's metadata and vault token; an unknown id is a no-op.
/// The vault entry is deleted only after the metadata write succeeds.
/// </summary>
public void Delete(Guid id)
{
lock (_gate)
{
var remaining = _accounts.Where(a => a.Id != id).ToList();
if (remaining.Count == _accounts.Count)
{
return;
}
Persist(remaining);
_accounts = remaining;
_vault.Delete(id);
}
}
/// <summary>
/// Stamps the outcome of a finished sync onto the account and persists it; every
/// other field is left untouched. An unknown id (account deleted while its sync
/// ran) is logged and ignored.
/// </summary>
public void RecordSyncResult(Guid id, DateTimeOffset lastSyncUtc, string summary)
{
lock (_gate)
{
int index = _accounts.FindIndex(a => a.Id == id);
if (index < 0)
{
_log.Error($"Cannot record a sync result: no account with id {id:N}.");
return;
}
var updated = new List<Account>(_accounts)
{
[index] = _accounts[index] with { LastSyncUtc = lastSyncUtc, LastSyncSummary = summary },
};
Persist(updated);
_accounts = updated;
}
}
private List<Account> Load()
{
try
{
if (!File.Exists(_filePath))
{
return [];
}
var loaded = JsonSerializer.Deserialize(
File.ReadAllText(_filePath), AccountsJsonContext.Default.ListAccount);
if (loaded is not null)
{
return loaded;
}
_log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts.");
PreserveCorruptFile();
}
catch (Exception ex)
{
_log.Error($"Failed to load accounts from '{_filePath}'; starting with no accounts.", ex);
PreserveCorruptFile();
}
return [];
}
/// <summary>
/// Moves an unreadable accounts file aside before the empty in-memory list can be
/// persisted over it: the metadata is what associates vault entries with accounts,
/// so clobbering a recoverable file would orphan every stored token.
/// </summary>
private void PreserveCorruptFile()
{
try
{
if (File.Exists(_filePath))
{
string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
File.Move(_filePath, aside, overwrite: true);
_log.Error($"Preserved unreadable accounts file as '{aside}'.");
}
}
catch (Exception ex)
{
_log.Error("Could not preserve the unreadable accounts file.", ex);
}
}
private void Persist(List<Account> accounts)
{
string? directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Atomic replace: a crash or full disk mid-write must never truncate the live
// file — corrupted metadata orphans every vault token (accounts are found by
// Guid, and recreated accounts mint new ones).
string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount);
string tempPath = _filePath + ".tmp";
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using (var writer = new StreamWriter(stream))
{
writer.Write(json);
writer.Flush();
stream.Flush(flushToDisk: true);
}
if (File.Exists(_filePath))
{
File.Replace(tempPath, _filePath, _filePath + ".bak");
}
else
{
File.Move(tempPath, _filePath);
}
}
}
/// <summary>
/// Source-generated serializer context so account (de)serialization keeps working in
/// trimmed Release publishes without reflection warnings.
/// </summary>
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(List<Account>))]
internal sealed partial class AccountsJsonContext : JsonSerializerContext
{
}
+147
View File
@@ -0,0 +1,147 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
namespace gclo.ViewModels;
/// <summary>
/// An <see cref="ITokenVault"/> backed by the Windows Credential Manager: each token
/// is a generic credential in the per-user store (persisted across reboots) whose
/// blob is the token's UTF-16 bytes, under the target name "gclo:account:&lt;id&gt;".
/// Talks to advapi32 directly, so it works packaged or unpackaged. Construction on a
/// non-Windows OS throws <see cref="PlatformNotSupportedException"/>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class CredentialManagerVault : ITokenVault
{
private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC
private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot
private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND
/// <summary>Fails fast on platforms without a Credential Manager.</summary>
public CredentialManagerVault()
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException(
"Account token storage requires the Windows Credential Manager.");
}
}
/// <inheritdoc/>
public void Store(Guid accountId, string token)
{
ArgumentNullException.ThrowIfNull(token);
byte[] blob = Encoding.Unicode.GetBytes(token);
var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned);
try
{
var credential = new CredentialW
{
Type = CredTypeGeneric,
TargetName = TargetName(accountId),
CredentialBlobSize = (uint)blob.Length,
CredentialBlob = pinned.AddrOfPinnedObject(),
Persist = CredPersistLocalMachine,
UserName = "gclo",
};
if (!CredWriteW(ref credential, 0))
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(
error, $"CredWriteW failed (error {error}) storing the token for account {accountId:N}.");
}
}
finally
{
// The array is pinned, so the GC cannot have copied it elsewhere: clearing
// it removes the one deterministic plaintext copy of the token from the
// heap (crash dumps, pagefile) before unpinning.
Array.Clear(blob);
pinned.Free();
}
}
/// <inheritdoc/>
public string? TryRetrieve(Guid accountId)
{
if (!CredReadW(TargetName(accountId), CredTypeGeneric, 0, out IntPtr credentialPtr))
{
int error = Marshal.GetLastWin32Error();
if (error == ErrorNotFound)
{
return null;
}
throw new Win32Exception(
error, $"CredReadW failed (error {error}) reading the token for account {accountId:N}.");
}
try
{
var credential = Marshal.PtrToStructure<CredentialW>(credentialPtr);
return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0
? ""
: Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
}
finally
{
CredFree(credentialPtr);
}
}
/// <inheritdoc/>
public void Delete(Guid accountId)
{
if (!CredDeleteW(TargetName(accountId), CredTypeGeneric, 0))
{
int error = Marshal.GetLastWin32Error();
if (error != ErrorNotFound)
{
throw new Win32Exception(
error, $"CredDeleteW failed (error {error}) deleting the token for account {accountId:N}.");
}
}
}
private static string TargetName(Guid accountId) => $"gclo:account:{accountId:N}";
/// <summary>
/// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout
/// both fixes the field order for marshaling and tells the compiler the unread
/// fields (filled by CredReadW) are intentional.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CredentialW
{
public uint Flags;
public uint Type;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetName;
[MarshalAs(UnmanagedType.LPWStr)] public string? Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias;
[MarshalAs(UnmanagedType.LPWStr)] public string? UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredWriteW(ref CredentialW credential, uint flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredDeleteW(string targetName, uint type, uint flags);
[DllImport("advapi32.dll", ExactSpelling = true)]
private static extern void CredFree(IntPtr buffer);
}
+23
View File
@@ -0,0 +1,23 @@
namespace gclo.ViewModels;
/// <summary>
/// Secure storage for account access tokens, keyed by account id. Tokens must never
/// reach accounts.json or the activity log; implementations keep them in a platform
/// secret store (<see cref="CredentialManagerVault"/>) or in process memory
/// (<see cref="InMemoryVault"/>).
/// </summary>
public interface ITokenVault
{
/// <summary>
/// Stores the token for an account, overwriting any existing entry. Throws on
/// failure — silently losing a token would leave the account unusable without
/// any warning to the user.
/// </summary>
void Store(Guid accountId, string token);
/// <summary>The stored token, or null when the vault has no entry for the account.</summary>
string? TryRetrieve(Guid accountId);
/// <summary>Removes the account's token. Deleting an absent entry is a no-op.</summary>
void Delete(Guid accountId);
}
+27
View File
@@ -0,0 +1,27 @@
using System.Collections.Concurrent;
namespace gclo.ViewModels;
/// <summary>
/// Thread-safe in-memory <see cref="ITokenVault"/>. Used by tests, and available for
/// scenarios where tokens must not outlive the process.
/// </summary>
public sealed class InMemoryVault : ITokenVault
{
private readonly ConcurrentDictionary<Guid, string> _tokens = new();
/// <inheritdoc/>
public void Store(Guid accountId, string token)
{
ArgumentNullException.ThrowIfNull(token);
_tokens[accountId] = token;
}
/// <inheritdoc/>
public string? TryRetrieve(Guid accountId)
=> _tokens.TryGetValue(accountId, out string? token) ? token : null;
/// <inheritdoc/>
public void Delete(Guid accountId)
=> _tokens.TryRemove(accountId, out _);
}