Phase 1: accounts foundation (model, vault, store, CLI) #25
No files matched your search
+86
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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:<id>".
|
||||
/// 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
Reference in new issue
Block a user