From ef86a52bc62b5efa9d53c8c34e9a5b9cf24841a0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 16:44:57 -0500 Subject: [PATCH 1/2] docs(plans): UX plan for the default token and account tokens Design for #101 (Settings shows a saved default token as a masked value) and #102 (Add account uses the default token by reference, so rotating it in Settings rotates every such account): TokenSource on the account record, one resolver for app and CLI, wizard step 2 as a choice, guarded removal in Settings, CLI parity, tests and delivery order. Relates to #101, #102 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- docs/plans/default-token-ux.md | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/plans/default-token-ux.md diff --git a/docs/plans/default-token-ux.md b/docs/plans/default-token-ux.md new file mode 100644 index 0000000..e287840 --- /dev/null +++ b/docs/plans/default-token-ux.md @@ -0,0 +1,173 @@ +# UX plan: the default token and account tokens + +> **Status: proposed** (2026-10-10). Delivers #101 (Settings shows a saved default +> token as a masked value) and #102 (Add account uses the default token by +> reference). Tracker of record: GitHub Issues; this document is the design. + +## The problem, as the owner sees it + +1. In **Settings**, a saved default token leaves the token box empty. An empty box + reads as "nothing saved", even with the caption underneath. +2. **Add account** always demands a token on step 2, even when a default token is + saved. Every account then holds its own copy, so rotating the default token in + Settings does nothing for existing accounts: each keeps the old copy until it + fails. + +## Design decisions + +- **An account references the default token; it never copies it.** A new field + `TokenSource` on the account record: `Default` (use whatever the default token + is right now) or `Own` (this account's own vault entry, today's behaviour). + Rotating the default token in Settings rotates every `Default` account at once. + `Own` accounts are untouched by the default, as today. +- **One resolver, used everywhere.** `AccountTokenResolver.Resolve(account)` in + `gclo.ViewModels` returns the token to use for an account (`Default` → vault + entry `DefaultTokenVaultId`; `Own` → vault entry `account.Id`) plus *why* it is + missing when it is. The desktop workspace, Sync All, the wizard's validation + and the CLI's `sync --account` all go through it. No consumer reads the vault + for an account directly any more. +- **The token itself is never shown.** The Settings box shows a fixed mask when a + token is saved; the wizard never loads a token into a box; the reveal button + of a `PasswordBox` can only ever reveal what the user typed in this session. +- **Backward compatible on disk.** `TokenSource` defaults to `Own` when absent, so + every existing `accounts.json` and every existing vault entry keeps working. + 1.0.x builds reading a newer file ignore the field (System.Text.Json default). +- **Deleting the default token is a guarded action** when `Default` accounts + exist: Settings says which accounts stop working and asks for confirmation. + +## Settings dialog (#101) + +| State | Token box | Caption | Remove link | +| --- | --- | --- | --- | +| No default token saved | empty, placeholder `ghp_…` | "No default token saved. Quick Sync will ask for one each launch." | hidden | +| Default token saved, untouched | shows the mask `••••••••••••••••` (a constant, never the token) | "A default token is saved and used by Quick Sync and *N* account(s). Type to replace it." | visible | +| User typed a new value | the typed value (masked) | "The default token will be replaced on Save." | visible | +| User cleared the box, or pressed Remove | empty | "The default token will be removed on Save. *N* account(s) use it and will stop syncing until a new one is saved." (plain "…will be removed on Save." when N = 0) | hidden | + +Save semantics: mask unchanged → keep; non-empty and not the mask → store; empty +→ remove (only if one was saved). Removing while `Default` accounts exist shows a +confirmation ("Remove the default token? These accounts use it: A, B.") before the +dialog closes; Cancel returns to the dialog with the mask restored. + +Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the +mask is announced as the field's value by the password pattern (dots), the +caption is a live region, and the Remove link's text names the consequence. + +## Add account / Edit account wizard (#102) + +Step 2 ("Access token") becomes a choice, shown only when a default token is +saved; without one the step looks exactly as today. + +```text +Step 2 of 4: Access token + +( ) Use the default token from Settings ← selected by default for a new account + Used by Quick Sync and N other account(s). Replacing it in Settings updates + this account too. +( ) Use a different token for this account + [ Personal access token ..................... ] (enabled only when selected) + How to create a token + +Next validates the chosen token and lists the organizations it can see. +``` + +- **New account:** "Use the default token" is preselected. Next validates the + *default* token through the resolver and lists its organizations. On save the + account is written with `TokenSource = Default` and **no vault entry** of its + own. Choosing "a different token" requires typing one; save writes + `TokenSource = Own` and the entry, as today. +- **Edit account:** the radio reflects the current source. Switching `Own → + Default` deletes the account's own vault entry on save (confirmed in the + caption: "This account's own token will be removed from Credential Manager"). + Switching `Default → Own` requires a token. Staying on `Own` with the box + empty keeps the stored token, as today. +- **Default token missing** (removed after the account was created): step 2 + shows "The default token is no longer saved. Save one in Settings, or use a + different token for this account." and Next stays put. +- **Seeded wizard** ("save this Quick Sync connection as an account"): if the + typed Quick Sync token equals the saved default token, the wizard preselects + "Use the default token"; otherwise "a different token" with the typed value. + (Comparison happens in memory in the view model; nothing is logged.) + +## Where the account's token shows up elsewhere + +- **Navigation pane / account header:** `Default` accounts get a small + "Default token" tag (text, not just an icon) in the account's header, so the + dependency on Settings is visible where the account is used. +- **Account workspace:** the token box is prefilled from the resolver as today. + For a `Default` account its header reads "Using the default token"; editing the + box affects this session only, exactly as for Quick Sync. +- **Sync All:** a `Default` account whose default token is missing is skipped + with the result "No default token saved (Settings)" instead of a generic auth + error. +- **Error messages:** every "no token" message names the source + ("the default token in Settings" or "this account's own token") and the fix. + +## CLI parity (`gclo accounts`, `gclo sync --account`) + +- `accounts add … --use-default-token` creates a `Default` account; the existing + `--token-env | --token-file | --token-stdin` create `Own` accounts. Exactly one + of the four is required (today: one of the three). +- `accounts edit --name X --use-default-token` switches to `Default` and removes + the own entry; a `--token-*` option switches to `Own`. +- `accounts list` gains a `token` column: `default` or `own`; the JSON output + gains `"tokenSource"`. +- `sync --account X` resolves through the resolver; its "no token" error names + the source and the fix (`gclo accounts edit --name X --token-stdin`, or saving + a default token in the desktop app's Settings; the CLI gets no way to *set* + the default token in this change). +- The default token stays a desktop-app concept to set, but the CLI reads it, + so a headless mirror can use `Default` accounts created on the same machine. + +## Data model and storage + +- `Account.TokenSource` (`enum TokenSource { Own, Default }`), serialized as a + string, default `Own`, `[JsonIgnoreCondition.WhenWritingDefault]` so existing + files stay byte-identical until an account actually uses the default. +- `AccountsStore.Save(account, token)` keeps its contract; a `Default` account is + always saved with `token: null`, and switching to `Default` removes the own + entry inside the same vault-first/compensate sequence the store already has. +- `AccountsStore.CountUsingDefaultToken()` feeds the Settings captions. +- Vault scoping (#60) is unchanged: `DefaultTokenVaultId` already lives in the + same scope as the accounts. + +## Security review (threat model T5, T6) + +- No new place holds a token: the mask is a constant; the resolver returns the + token to the same callers that already received it; nothing is logged. +- A `Default` account widens the blast radius of the default token to every + such account by design; the Settings warning on removal and the "Default + token" tag make that dependency visible. Rotation becomes a single action, + which is the point. +- The CLI still has no `--token `; `--use-default-token` carries no + secret on the command line. + +## Tests + +- ViewModels (100% line coverage stays enforced): resolver (both sources, + missing cases), wizard step 2 for new/edit/seeded in both sources and the + missing-default error, store save/switch/compensation with `TokenSource`, + settings captions and remove-with-dependents counts (through a small + `SettingsTokenState` view model so the dialog has no logic of its own), + Sync All skip reason. +- CLI (100%): `--use-default-token` on add/edit, mutual exclusion with + `--token-*`, list column and JSON field, sync resolution error text. +- FlaUI: Settings shows the mask when a token is saved and an empty box when + not; Add account shows the radio only when a default token exists, and the + token box is disabled while "Use the default token" is selected. +- Serialization round-trip: an `accounts.json` without the field loads as `Own` + and re-saves without the field. + +## Delivery + +One issue per layer, one PR each, in this order, every PR green on all gates: + +1. **#102a – model + resolver + store + CLI** (no UI change yet; `Default` + accounts can only be created by the CLI so far). Threat model updated. +2. **#102b – wizard + pane tag + Sync All reason** (desktop). +3. **#101 – Settings dialog** (mask, captions, guarded removal), last because its + "used by N accounts" count depends on step 1. + +Ships in the next minor (1.1.0): it adds a field to the account record and a +CLI option, which is a feature, not a fix; 1.0.2 stays the pipeline/data-root +fix release. -- 2.54.0 From c0a99cacd8327129cd63876e8aa3d257d9493847 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sat, 10 Oct 2026 17:03:16 -0500 Subject: [PATCH 2/2] feat(accounts): reference the default token instead of copying it An account can now resolve to the default token saved in Settings (TokenSource = Default) instead of holding a copy of its own, so replacing the default token rotates every such account at once. Own stays the on-disk default and is omitted from accounts.json, so existing files and vault entries are untouched. AccountTokenResolver is the one place that turns an account into its token; the desktop workspace, Sync All and the CLI go through it. AccountsStore saves a Default account without a vault entry and removes an own entry when an account switches, vault first with the same put-it-back compensation as a delete; CountUsingDefaultToken feeds the Settings captions in step 3. CLI parity: `accounts add` and `accounts edit` take --use-default-token (a fatal error when no default token is saved; exclusive with the other token options), `accounts list` shows the source as a column and as tokenSource in JSON, and `sync --account` names the source and the fix when no token resolves. Threat model T5 and docs/CLI.md updated; the approved plan is in docs/plans/default-token-ux.md. Closes #104 Part of #102 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- docs/CLI.md | 34 +++-- docs/plans/default-token-ux.md | 7 +- docs/security/threat-model.md | 3 +- gclo.Cli.Tests/AccountsCommandTests.cs | 138 +++++++++++++++++ gclo.Cli.Tests/CliTestSupport.cs | 4 + gclo.Cli.Tests/SyncCommandTests.cs | 34 +++++ gclo.Cli.Tests/TokenOptionsTests.cs | 31 ++++ gclo.Cli/AccountSummary.cs | 3 +- gclo.Cli/AccountsCommand.cs | 76 ++++++++-- gclo.Cli/SyncCommand.cs | 15 +- gclo.Cli/TokenOptions.cs | 42 +++++- .../AccountTokenResolverTests.cs | 139 ++++++++++++++++++ .../AccountsStoreConsistencyTests.cs | 61 ++++++++ gclo.Engine.Tests/AccountsStoreTests.cs | 105 +++++++++++++ gclo.Engine.Tests/WorkspaceViewModelTests.cs | 48 ++++++ gclo.ViewModels/Account.cs | 21 ++- gclo.ViewModels/AccountTokenResolver.cs | 63 ++++++++ gclo.ViewModels/AccountsStore.cs | 67 +++++++++ gclo.ViewModels/TokenSource.cs | 23 +++ gclo.ViewModels/WorkspaceViewModel.cs | 5 +- 20 files changed, 874 insertions(+), 45 deletions(-) create mode 100644 gclo.Engine.Tests/AccountTokenResolverTests.cs create mode 100644 gclo.ViewModels/AccountTokenResolver.cs create mode 100644 gclo.ViewModels/TokenSource.cs diff --git a/docs/CLI.md b/docs/CLI.md index 09e2d76..649a4e9 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -244,23 +244,26 @@ the app's account wizard, and use them from either. ### `gclo accounts` / `gclo accounts list` 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): +target root, token source (`own` or `default`), 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 +work contoso C:\src\contoso own 2026-07-04 09:12 +personal octocat D:\mirror default never ``` With `--json` it prints a single-line JSON array with everything a script needs to reason about a run; `lastSync` is the UTC timestamp of the last completed -sync and `lastSyncSummary` its summary line, both `null` until the first sync: +sync and `lastSyncSummary` its summary line, both `null` until the first sync; +`tokenSource` is `own` or `default`: ```json -[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44."}] +[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}] ``` -`id` is the key of the account's Credential Manager entry (`gclo:account:`). +`id` is the key of the account's Credential Manager entry (`gclo:account:`) +for an `own` account; a `default` account has no entry of its own and resolves +to the default token saved in the desktop app's Settings whenever it syncs. When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a hint goes to stderr; the exit code is still 0. @@ -277,6 +280,16 @@ variable) and is written to Windows Credential Manager — never put it on the command line. A name that another account already uses (case-insensitive) exits with code 2. +With `--use-default-token` instead of a token option, the account holds no +token of its own: it resolves to the default token saved in the desktop app's +Settings every time it syncs, so replacing that token in Settings updates every +such account at once. The command fails (exit 2) when no default token is saved, +because the account could never sync; the CLI cannot set the default token. + +```powershell +gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token +``` + ### `gclo accounts edit` ```powershell @@ -286,8 +299,11 @@ gclo accounts edit --name work --rename "work (contoso)" ``` `--name` picks the account; every other option replaces just that setting. -Without a token option the stored token is left untouched. Editing with -nothing to change is a usage error. +Without a token option the token (and its source) is left untouched. A +`--token-*` option gives the account a token of its own (also switching a +`default` account to `own`); `--use-default-token` switches it to the default +token and removes its own entry from Credential Manager. Editing with nothing +to change is a usage error. ### `gclo accounts remove` diff --git a/docs/plans/default-token-ux.md b/docs/plans/default-token-ux.md index e287840..066c8d9 100644 --- a/docs/plans/default-token-ux.md +++ b/docs/plans/default-token-ux.md @@ -1,8 +1,9 @@ # UX plan: the default token and account tokens -> **Status: proposed** (2026-10-10). Delivers #101 (Settings shows a saved default -> token as a masked value) and #102 (Add account uses the default token by -> reference). Tracker of record: GitHub Issues; this document is the design. +> **Status: approved** by the owner on 2026-10-10; step 1 is #104. Delivers #101 +> (Settings shows a saved default token as a masked value) and #102 (Add account +> uses the default token by reference). Tracker of record: GitHub Issues; this +> document is the design. ## The problem, as the owner sees it diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 08ca67a..d676c74 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -87,7 +87,7 @@ flowchart LR | T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated | | T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated | | T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated | -| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated | +| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated | | T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) | | T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated | | T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated | @@ -114,3 +114,4 @@ flowchart LR | 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T11: the packaging PR is opened with a short-lived release GitHub App token (contents + pull requests on this repository, no bypass) instead of a long-lived PAT; the commit is GitHub-signed (#85) | | 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) | | 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) | +| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) | diff --git a/gclo.Cli.Tests/AccountsCommandTests.cs b/gclo.Cli.Tests/AccountsCommandTests.cs index 56a3010..7b0038d 100644 --- a/gclo.Cli.Tests/AccountsCommandTests.cs +++ b/gclo.Cli.Tests/AccountsCommandTests.cs @@ -234,6 +234,144 @@ public sealed class AccountsCommandTests : IDisposable + // ---------------------------------------------------------------- default token (#102) + + [Fact] + public void Add_UseDefaultToken_CreatesAnAccountWithoutAnOwnEntry() + { + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + using var console = new ConsoleCapture(); + + int code = AccountsCommand.Run( + ["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()); + + Assert.Equal(0, code); + Account saved = Store.FindByName("shared")!; + Assert.True(saved.UsesDefaultToken); + Assert.Null(_vault.TryRetrieve(saved.Id)); + Assert.Contains("uses the default token", console.Out); + } + + + + [Fact] + public void Add_UseDefaultToken_WithoutASavedDefault_IsAFatalError() + { + CliErrorException ex = Assert.Throws(() => AccountsCommand.Run( + ["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog())); + + Assert.Contains("No default token is saved", ex.Message); + Assert.Contains("Settings", ex.Message); + Assert.Empty(Store.GetAll()); + } + + + + [Fact] + public void Add_UseDefaultToken_WithAnotherTokenOption_IsAUsageError() + { + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + using IDisposable _ = TokenEnv("ghp_x"); + + Assert.Throws(() => AccountsCommand.Run( + ["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token", "--token-env", EnvVar], + Open, new NullLog())); + Assert.Empty(Store.GetAll()); + } + + + + [Fact] + public void Edit_UseDefaultToken_SwitchesTheAccount_AndRemovesItsOwnToken() + { + Seed("work", "acme", @"C:\a"); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + Guid id = Store.FindByName("work")!.Id; + Assert.Equal("tok-work", _vault.TryRetrieve(id)); + using var console = new ConsoleCapture(); + + int code = AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog()); + + Assert.Equal(0, code); + Assert.True(Store.FindByName("work")!.UsesDefaultToken); + Assert.Null(_vault.TryRetrieve(id)); + Assert.Contains("now uses the default token", console.Out); + } + + + + [Fact] + public void Edit_UseDefaultToken_WithoutASavedDefault_IsAFatalError_AndKeepsTheOwnToken() + { + Seed("work", "acme", @"C:\a"); + Guid id = Store.FindByName("work")!.Id; + + Assert.Throws(() => AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog())); + + Assert.False(Store.FindByName("work")!.UsesDefaultToken); + Assert.Equal("tok-work", _vault.TryRetrieve(id)); + } + + + + [Fact] + public void Edit_TokenOption_SwitchesADefaultTokenAccountToItsOwnToken() + { + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()); + using IDisposable _ = TokenEnv("ghp_mine"); + + int code = AccountsCommand.Run(["edit", "--name", "shared", "--token-env", EnvVar], Open, new NullLog()); + + Assert.Equal(0, code); + Account updated = Store.FindByName("shared")!; + Assert.False(updated.UsesDefaultToken); + Assert.Equal("ghp_mine", _vault.TryRetrieve(updated.Id)); + } + + + + [Fact] + public void Edit_OtherSettings_LeaveTheTokenSourceAlone() + { + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()); + + AccountsCommand.Run(["edit", "--name", "shared", "--parallel", "3"], Open, new NullLog()); + + Account updated = Store.FindByName("shared")!; + Assert.True(updated.UsesDefaultToken); + Assert.Equal(3, updated.MaxConcurrency); + } + + + + [Fact] + public void List_ShowsTheTokenSource_InColumnsAndJson() + { + Seed("work", "acme", @"C:\a"); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\b", "--use-default-token"], Open, new NullLog()); + + using(var console = new ConsoleCapture()) + { + Assert.Equal(0, AccountsCommand.Run([], Open, new NullLog())); + string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Contains(lines, l => l.StartsWith("shared", StringComparison.Ordinal) && l.Contains(" default ", StringComparison.Ordinal)); + Assert.Contains(lines, l => l.StartsWith("work", StringComparison.Ordinal) && l.Contains(" own ", StringComparison.Ordinal)); + } + + using(var console = new ConsoleCapture()) + { + Assert.Equal(0, AccountsCommand.Run(["--json"], Open, new NullLog())); + Assert.Contains("\"name\":\"shared\"", console.Out); + Assert.Contains("\"tokenSource\":\"default\"", console.Out); + Assert.Contains("\"tokenSource\":\"own\"", console.Out); + } + } + + + [Fact] public void Add_UnknownOption_Throws() => Assert.Throws(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog())); diff --git a/gclo.Cli.Tests/CliTestSupport.cs b/gclo.Cli.Tests/CliTestSupport.cs index 2736ef9..1b95cea 100644 --- a/gclo.Cli.Tests/CliTestSupport.cs +++ b/gclo.Cli.Tests/CliTestSupport.cs @@ -91,12 +91,16 @@ internal sealed class FakeRepoLister : IRepositoryLister public Exception? Throw { get; set; } + /// The token of the most recent call, so a test can check which source was used. + public string? LastToken { get; private set; } + public Task> ListOrganizationRepositoriesAsync( string organization, string token, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + LastToken = token; return Throw is not null ? Task.FromException>(Throw) : Task.FromResult(Result); diff --git a/gclo.Cli.Tests/SyncCommandTests.cs b/gclo.Cli.Tests/SyncCommandTests.cs index 60539b6..b6c72d4 100644 --- a/gclo.Cli.Tests/SyncCommandTests.cs +++ b/gclo.Cli.Tests/SyncCommandTests.cs @@ -579,6 +579,40 @@ public sealed class SyncCommandTests : IDisposable + [Fact] + public async Task Account_UsingTheDefaultToken_SyncsWithIt() + { + Account account = Seed("shared", "acme"); + _vault.Delete(account.Id); + Store.Save(account with { TokenSource = TokenSource.Default }, null); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--account", "shared"); + + Assert.Equal(0, code); + Assert.Equal("ghp_default", lister.LastToken); + } + + + + [Fact] + public async Task Account_UsingTheDefaultToken_WithoutOne_NamesSettingsInTheError() + { + Account account = Seed("shared", "acme"); + _vault.Delete(account.Id); + Store.Save(account with { TokenSource = TokenSource.Default }, null); + + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--account", "shared")); + + Assert.Contains("no default token is saved", ex.Message); + Assert.Contains("Settings", ex.Message); + Assert.DoesNotContain("gclo:account:", ex.Message); + } + + + [Fact] public async Task Account_ExplicitTokenOption_OverridesVault() { diff --git a/gclo.Cli.Tests/TokenOptionsTests.cs b/gclo.Cli.Tests/TokenOptionsTests.cs index 7739880..abaffe8 100644 --- a/gclo.Cli.Tests/TokenOptionsTests.cs +++ b/gclo.Cli.Tests/TokenOptionsTests.cs @@ -26,6 +26,37 @@ public sealed class TokenOptionsTests + [Fact] + public void UseDefaultToken_IsAnExplicitSource_ThatResolvesToNoValue() + { + TokenOptions options = Parse("--use-default-token"); + + Assert.True(options.HasExplicitSource); + Assert.True(options.UseDefault); + CliUsageException ex = Assert.Throws(() => options.Resolve()); + Assert.Contains("--use-default-token", ex.Message); + Assert.Contains("accounts add", ex.Message); + } + + + + [Fact] + public void UseDefaultToken_CombinedWithAnotherSource_IsAUsageError() + { + CliUsageException ex = Assert.Throws(() => Parse("--use-default-token", "--token-stdin").Resolve()); + + Assert.Contains("only one of", ex.Message); + Assert.Contains("--use-default-token", ex.Message); + } + + + + [Fact] + public void UseDefaultToken_RejectsAValue() + => Assert.Throws(() => Parse("--use-default-token=yes")); + + + [Fact] public void TryConsume_NonTokenOption_ReturnsFalse() { diff --git a/gclo.Cli/AccountSummary.cs b/gclo.Cli/AccountSummary.cs index 23f58e9..ffc8465 100644 --- a/gclo.Cli/AccountSummary.cs +++ b/gclo.Cli/AccountSummary.cs @@ -21,4 +21,5 @@ internal sealed record AccountSummary( bool CreateOrgSubfolder, int MaxConcurrency, DateTimeOffset? LastSync, - string? LastSyncSummary); + string? LastSyncSummary, + string TokenSource); diff --git a/gclo.Cli/AccountsCommand.cs b/gclo.Cli/AccountsCommand.cs index 9483d15..4cdfad6 100644 --- a/gclo.Cli/AccountsCommand.cs +++ b/gclo.Cli/AccountsCommand.cs @@ -32,12 +32,14 @@ internal static class AccountsCommand list (default) Prints the 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). + organization, target root, token source ('own' or 'default'), and last + sync time (local time, 'never' when the account has not completed a + sync yet). --json Print a single-line JSON array instead: [{"id":"...","name":"...","description":"...","organization":"...", "targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N, - "lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null}] + "lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null, + "tokenSource":"own"|"default"}] add --name Display name; must be unique (case-insensitive). @@ -49,6 +51,10 @@ internal static class AccountsCommand --token-env | --token-file | --token-stdin The token to store (default: the GITHUB_TOKEN environment variable). Never pass it as an argument. + --use-default-token Instead of a token of its own, the account uses + the default token saved in the desktop app's + Settings, read whenever it syncs, so replacing + the default token there updates this account too. edit --name The account to change (required). @@ -58,8 +64,11 @@ internal static class AccountsCommand --org-subfolder | --no-org-subfolder Turn the organization subfolder on or off. --token-env | --token-file | --token-stdin - Replace the stored token; without a token option - the stored token is left untouched. + Replace the stored token (and switch an account + on the default token to a token of its own); + without a token option the token is left untouched. + --use-default-token Switch the account to the default token; its own + token is removed from Windows Credential Manager. remove --name Deletes the account's settings and its stored @@ -137,7 +146,7 @@ internal static class AccountsCommand IReadOnlyList summaries = accounts .Select(a => new AccountSummary( a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot, - a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary)) + a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a))) .ToList(); Console.Out.WriteLine(JsonSerializer.Serialize( summaries, CliJsonContext.Default.IReadOnlyListAccountSummary)); @@ -158,7 +167,7 @@ internal static class AccountsCommand { Console.Out.WriteLine( $"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} " - + $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}"); + + $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}"); } return ExitCodes.Success; } @@ -234,11 +243,14 @@ internal static class AccountsCommand throw new CliUsageException("--target is required."); } - log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'"); + log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}"); try { - string token = tokenOptions.Resolve(); - (AccountsStore store, _) = open(log); + // The token (if the account gets one of its own) is read before the store + // opens, so a bad token option fails without touching accounts.json. + string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve(); + (AccountsStore store, ITokenVault vault) = open(log); + RequireDefaultTokenIfUsed(tokenOptions, vault); var account = new Account { Id = Guid.NewGuid(), @@ -248,9 +260,10 @@ internal static class AccountsCommand TargetRoot = target.Trim(), CreateOrgSubfolder = orgSubfolder, MaxConcurrency = parallel, + TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own, }; SaveOrFail(store, account, token); - Console.Out.WriteLine($"Account '{account.Name}' added."); + Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : ".")); return ExitCodes.Success; } catch(Exception ex) @@ -332,10 +345,17 @@ internal static class AccountsCommand log.Info($"accounts edit started: name='{name.Trim()}'"); try { - (AccountsStore store, _) = open(log); + (AccountsStore store, ITokenVault vault) = open(log); Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store); - string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null; + RequireDefaultTokenIfUsed(tokenOptions, vault); + string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null; + // A token option decides the source: --use-default-token switches to the + // default token (and drops the own entry); --token-* gives the account a + // token of its own; neither leaves the source and the token untouched. + TokenSource source = tokenOptions.UseDefault + ? TokenSource.Default + : token is null ? existing.TokenSource : TokenSource.Own; Account updated = existing with { Name = rename?.Trim() ?? existing.Name, @@ -344,9 +364,13 @@ internal static class AccountsCommand Description = description?.Trim() ?? existing.Description, MaxConcurrency = parallel ?? existing.MaxConcurrency, CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder, + TokenSource = source, }; SaveOrFail(store, updated, token); - Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced).")); + string tokenNote = tokenOptions.UseDefault + ? " (now uses the default token)." + : token is null ? "." : " (token replaced)."; + Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote); return ExitCodes.Success; } catch(Exception ex) @@ -405,6 +429,30 @@ internal static class AccountsCommand // ---------------------------------------------------------------- shared + /// 'own' or 'default': the token source as the list column and the JSON field show it. + private static string TokenSourceLabel(Account account) => account.UsesDefaultToken ? "default" : "own"; + + + + /// + /// --use-default-token is only useful when a default token exists: without one + /// the account could never sync, so the command fails up front and says where + /// the default token is set (the CLI has no way to set it). + /// + private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault) + { + tokenOptions.EnsureAtMostOneSource(); + if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken) + { + throw new CliErrorException( + "No default token is saved, so the account could not sync. Save one in the desktop app " + + "(Settings > Default GitHub token), or give the account a token of its own with --token-env, " + + "--token-file, or --token-stdin."); + } + } + + + /// A duplicate name is the one validation the store itself enforces; everything else is fatal as is. private static void SaveOrFail(AccountsStore store, Account account, string? token) { diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index 1da124f..e4b2b27 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -468,14 +468,17 @@ internal static class SyncCommand } // vault is non-null whenever account is: both come from AccountsCommand.Open. - string? token = vault!.TryRetrieve(account.Id); - if(string.IsNullOrEmpty(token)) + string? token = new AccountTokenResolver(vault!).TryResolve(account); + if(token is null) { + string fix = account.UsesDefaultToken + ? "Save a default token in the desktop app's Settings, switch the account to its own token with " + + "'gclo accounts edit --name --token-stdin'," + : "Re-enter the token with 'gclo accounts edit --name --token-stdin' or in the desktop app's " + + "account wizard, restore the credential entry manually,"; throw new CliErrorException( - $"Account '{account.Name}' has no token in Windows Credential Manager " - + $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with " - + "'gclo accounts edit --name --token-stdin' or in the desktop app's account wizard, " - + "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin."); + $"Account '{account.Name}' {AccountTokenResolver.MissingDescription(account)}. {fix} " + + "or pass a token with --token-env, --token-file, or --token-stdin."); } return token; } diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs index e641e61..72ba054 100644 --- a/gclo.Cli/TokenOptions.cs +++ b/gclo.Cli/TokenOptions.cs @@ -25,14 +25,25 @@ internal sealed class TokenOptions private bool _useStdin; + private bool _useDefault; + /// - /// 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. + /// True when any token option (--token-env, --token-file, --token-stdin, + /// --use-default-token) was given. 'gclo sync --account' uses this to let an + /// explicit token source override the account's stored token. /// - public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin; + public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin || _useDefault; + + + + /// + /// --use-default-token: the account resolves to the default token saved in the + /// desktop app's Settings instead of holding a token of its own (accounts add + /// and edit only). rejects it, as it names no token value. + /// + public bool UseDefault => _useDefault; @@ -51,6 +62,10 @@ internal sealed class TokenOptions reader.RejectValue(); _useStdin = true; return true; + case "--use-default-token": + reader.RejectValue(); + _useDefault = true; + return true; default: return false; } @@ -58,6 +73,18 @@ internal sealed class TokenOptions + /// Usage error when more than one token option was given; a no-op otherwise. + public void EnsureAtMostOneSource() + { + int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0) + (_useDefault ? 1 : 0); + if(sources > 1) + { + throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin, --use-default-token."); + } + } + + + /// /// Resolves the token from the single configured source, or from the /// environment variable when none was given. @@ -65,10 +92,11 @@ internal sealed class TokenOptions /// public string Resolve() { - int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0); - if(sources > 1) + EnsureAtMostOneSource(); + if(_useDefault) { - throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin."); + throw new CliUsageException( + "--use-default-token names no token value; it is for 'gclo accounts add' and 'gclo accounts edit' only."); } if(_useStdin) diff --git a/gclo.Engine.Tests/AccountTokenResolverTests.cs b/gclo.Engine.Tests/AccountTokenResolverTests.cs new file mode 100644 index 0000000..91ec697 --- /dev/null +++ b/gclo.Engine.Tests/AccountTokenResolverTests.cs @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +/// +/// : an account's own entry for , +/// the default token for , null (with a source-specific +/// description) when the source holds nothing. +/// +public sealed class AccountTokenResolverTests +{ + private readonly InMemoryVault _vault = new(); + + + + private static Account MakeAccount(TokenSource source) => new() + { + Id = Guid.NewGuid(), + Name = "work", + Organization = "acme", + TargetRoot = @"C:\repos", + TokenSource = source, + }; + + + + [Fact] + public void NullVault_Throws() + => Assert.Throws(() => new AccountTokenResolver(null!)); + + + + [Fact] + public void NullAccount_Throws() + { + var resolver = new AccountTokenResolver(_vault); + + Assert.Throws(() => resolver.TryResolve(null!)); + Assert.Throws(() => AccountTokenResolver.MissingDescription(null!)); + } + + + + [Fact] + public void OwnAccount_ResolvesItsOwnEntry_NotTheDefault() + { + Account account = MakeAccount(TokenSource.Own); + _vault.Store(account.Id, "ghp_own"); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + + Assert.Equal("ghp_own", new AccountTokenResolver(_vault).TryResolve(account)); + } + + + + [Fact] + public void DefaultAccount_ResolvesTheDefaultToken_EvenWhenAnOwnEntryLingers() + { + Account account = MakeAccount(TokenSource.Default); + _vault.Store(account.Id, "ghp_stale"); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + + Assert.Equal("ghp_default", new AccountTokenResolver(_vault).TryResolve(account)); + } + + + + [Fact] + public void DefaultAccount_FollowsRotationOfTheDefaultToken() + { + Account account = MakeAccount(TokenSource.Default); + var resolver = new AccountTokenResolver(_vault); + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_first"); + Assert.Equal("ghp_first", resolver.TryResolve(account)); + + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_rotated"); + + Assert.Equal("ghp_rotated", resolver.TryResolve(account)); + } + + + + [Theory] + [InlineData(TokenSource.Own)] + [InlineData(TokenSource.Default)] + public void MissingSource_ResolvesNull(TokenSource source) + { + Account account = MakeAccount(source); + + Assert.Null(new AccountTokenResolver(_vault).TryResolve(account)); + } + + + + [Fact] + public void EmptyStoredValue_CountsAsMissing() + { + Account account = MakeAccount(TokenSource.Own); + _vault.Store(account.Id, ""); + + Assert.Null(new AccountTokenResolver(_vault).TryResolve(account)); + } + + + + [Fact] + public void HasDefaultToken_ReflectsTheVault() + { + var resolver = new AccountTokenResolver(_vault); + Assert.False(resolver.HasDefaultToken); + + _vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default"); + + Assert.True(resolver.HasDefaultToken); + } + + + + [Fact] + public void MissingDescription_NamesTheSource_AndNeverATokenValue() + { + Account own = MakeAccount(TokenSource.Own); + Account byDefault = MakeAccount(TokenSource.Default); + + string ownText = AccountTokenResolver.MissingDescription(own); + string defaultText = AccountTokenResolver.MissingDescription(byDefault); + + Assert.Contains($"gclo:account:{own.Id:N}", ownText); + Assert.Contains("Settings", defaultText); + Assert.DoesNotContain("ghp", ownText + defaultText); + } +} diff --git a/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs b/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs index 755a8a7..6ddf8a1 100644 --- a/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs +++ b/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs @@ -388,6 +388,67 @@ public sealed class AccountsStoreConsistencyTests : IDisposable /// Delete can be made to throw (from a given call number on) without changing /// state — the contract a real secret store keeps on failure. /// + // ---------------------------------------------------------------- save: switching to the default token (#102) + + [Fact] + public void Save_SwitchToDefaultToken_MetadataWriteFails_RestoresTheOwnToken() + { + AccountsStore store = NewStore(); + Account account = MakeAccount("Work"); + store.Save(account, OldToken); + BlockMetadataWrites(); + _vault.ResetCounters(); + + Assert.Throws(() => store.Save(account with { TokenSource = TokenSource.Default }, null)); + + Assert.Equal(OldToken, _vault.TryRetrieve(account.Id)); + Assert.Equal(1, _vault.DeleteCalls); + Assert.Equal(1, _vault.StoreCalls); + Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken); + Assert.False(Assert.Single(OnDisk()).UsesDefaultToken); + } + + + + [Fact] + public void Save_SwitchToDefaultToken_MetadataWriteFails_WithNoOwnToken_JustPropagates() + { + AccountsStore store = NewStore(); + Account account = MakeAccount("Work"); + store.Save(account, OldToken); + _vault.Delete(account.Id); // nothing of its own to put back + BlockMetadataWrites(); + _vault.ResetCounters(); + + Assert.Throws(() => store.Save(account with { TokenSource = TokenSource.Default }, null)); + + Assert.Equal(0, _vault.DeleteCalls); + Assert.Equal(0, _vault.StoreCalls); + } + + + + [Fact] + public void Save_SwitchToDefaultToken_MetadataWriteFails_AndRestoreFails_ReportsBoth() + { + AccountsStore store = NewStore(); + Account account = MakeAccount("Work"); + store.Save(account, OldToken); + BlockMetadataWrites(); + _vault.StoreFailure = new InvalidOperationException("vault offline"); + _vault.ResetCounters(); + + AccountConsistencyException ex = Assert.Throws( + () => store.Save(account with { TokenSource = TokenSource.Default }, null)); + + Assert.Contains("vault offline", ex.Message); + Assert.Contains("lost its own token", ex.Message); + Assert.Null(_vault.TryRetrieve(account.Id)); + Assert.Contains(_log.Errors, e => e.Message.Contains("could not be restored", StringComparison.Ordinal)); + } + + + private sealed class FaultableVault : ITokenVault { private readonly InMemoryVault _inner = new(); diff --git a/gclo.Engine.Tests/AccountsStoreTests.cs b/gclo.Engine.Tests/AccountsStoreTests.cs index cce8c3a..c978ea0 100644 --- a/gclo.Engine.Tests/AccountsStoreTests.cs +++ b/gclo.Engine.Tests/AccountsStoreTests.cs @@ -42,6 +42,111 @@ public sealed class AccountsStoreTests : IDisposable + // ---------------------------------------------------------------- default token (#102) + + [Fact] + public void Save_DefaultTokenAccount_WritesNoOwnEntry_AndRoundTripsTheSource() + { + Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default }; + + NewStore().Save(account, token: null); + + Assert.Null(_vault.TryRetrieve(account.Id)); + Account reloaded = Assert.Single(NewStore().GetAll()); + Assert.Equal(TokenSource.Default, reloaded.TokenSource); + Assert.True(reloaded.UsesDefaultToken); + Assert.Contains("\"TokenSource\": \"Default\"", File.ReadAllText(Path.Combine(_root, "accounts.json"))); + } + + + + [Fact] + public void Save_OwnTokenAccount_OmitsTheSourceFromTheFile_SoOldFilesStayIdentical() + { + NewStore().Save(MakeAccount("Work"), "ghp_own"); + + string json = File.ReadAllText(Path.Combine(_root, "accounts.json")); + Assert.DoesNotContain("TokenSource", json); + Assert.Equal(TokenSource.Own, Assert.Single(NewStore().GetAll()).TokenSource); + } + + + + [Fact] + public void Load_FileWithoutTheSourceField_ReadsAsOwn() + { + Directory.CreateDirectory(_root); + var id = Guid.NewGuid(); + File.WriteAllText( + Path.Combine(_root, "accounts.json"), + $$"""[{"Id":"{{id}}","Name":"Old","Organization":"acme","TargetRoot":"C:\\repos"}]"""); + + Account loaded = Assert.Single(NewStore().GetAll()); + + Assert.Equal(TokenSource.Own, loaded.TokenSource); + Assert.False(loaded.UsesDefaultToken); + } + + + + [Fact] + public void Save_DefaultTokenAccount_WithAToken_IsRejected() + { + Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default }; + + ArgumentException ex = Assert.Throws(() => NewStore().Save(account, "ghp_stray")); + + Assert.Contains("default token", ex.Message); + Assert.Empty(NewStore().GetAll()); + Assert.Null(_vault.TryRetrieve(account.Id)); + } + + + + [Fact] + public void Save_SwitchingToTheDefaultToken_RemovesTheOwnEntry() + { + AccountsStore store = NewStore(); + Account account = MakeAccount("Work"); + store.Save(account, "ghp_own"); + Assert.Equal("ghp_own", _vault.TryRetrieve(account.Id)); + + store.Save(account with { TokenSource = TokenSource.Default }, token: null); + + Assert.Null(_vault.TryRetrieve(account.Id)); + Assert.True(Assert.Single(store.GetAll()).UsesDefaultToken); + } + + + + [Fact] + public void Save_SwitchingBackToAnOwnToken_StoresIt() + { + AccountsStore store = NewStore(); + Account account = MakeAccount("Work") with { TokenSource = TokenSource.Default }; + store.Save(account, token: null); + + store.Save(account with { TokenSource = TokenSource.Own }, "ghp_mine"); + + Assert.Equal("ghp_mine", _vault.TryRetrieve(account.Id)); + Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken); + } + + + + [Fact] + public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts() + { + AccountsStore store = NewStore(); + Assert.Equal(0, store.CountUsingDefaultToken()); + store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null); + store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null); + store.Save(MakeAccount("C"), "ghp_c"); + + Assert.Equal(2, store.CountUsingDefaultToken()); + Assert.Equal(2, NewStore().CountUsingDefaultToken()); + } + // ---------------------------------------------------------------- persistence [Fact] diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index cf3933d..297cf11 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -1793,6 +1793,54 @@ public sealed class WorkspaceViewModelTests : IDisposable + [Fact] + public async Task AccountConstruction_DefaultTokenAccount_UsesTheDefaultToken() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Shared", + Organization = "acme", + TargetRoot = @"C:\repos\shared", + TokenSource = TokenSource.Default, + }; + var vault = new InMemoryVault(); + vault.Store(AppSettings.DefaultTokenVaultId, "default-token-1234567890"); + string? lookedUpToken = null; + _orgs.Handler = (token, _) => + { + lookedUpToken = token; + return Task.FromResult>(["acme"]); + }; + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault); + + Assert.Equal("default-token-1234567890", vm.Token); + await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire"); + Assert.Equal("default-token-1234567890", lookedUpToken); + } + + + + [Fact] + public void AccountConstruction_DefaultTokenAccount_WithoutADefaultToken_StartsEmpty() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Shared", + Organization = "acme", + TargetRoot = @"C:\repos\shared", + TokenSource = TokenSource.Default, + }; + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: new InMemoryVault()); + + Assert.Equal("", vm.Token); + } + + + [Fact] public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken() { diff --git a/gclo.ViewModels/Account.cs b/gclo.ViewModels/Account.cs index a92d55d..facefd1 100644 --- a/gclo.ViewModels/Account.cs +++ b/gclo.ViewModels/Account.cs @@ -3,20 +3,37 @@ * SPDX-License-Identifier: MIT */ +using System.Text.Json.Serialization; + namespace gclo.ViewModels; /// /// 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 keyed by and is never -/// serialized alongside the account. +/// separately in an (keyed by , or the +/// default token when says so) and is never serialized +/// alongside the account. /// public sealed record Account { /// Stable identity: keys the vault entry and survives renames. public required Guid Id { get; init; } + /// + /// Where the token comes from: this account's own vault entry (the default, and + /// omitted from accounts.json so files written before this field exist stay + /// byte-identical) or the default token from Settings, resolved at use time by + /// . + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public TokenSource TokenSource { get; init; } = TokenSource.Own; + + /// True when the account resolves to the default token from Settings. + [JsonIgnore] + public bool UsesDefaultToken => TokenSource == TokenSource.Default; + /// Display name; unique across accounts (case-insensitive). public required string Name { get; init; } diff --git a/gclo.ViewModels/AccountTokenResolver.cs b/gclo.ViewModels/AccountTokenResolver.cs new file mode 100644 index 0000000..6deaa70 --- /dev/null +++ b/gclo.ViewModels/AccountTokenResolver.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// The one place that turns an into the token to use for it: +/// its own vault entry for , the default token from +/// Settings for . The desktop workspace, Sync All, +/// the account wizard and the CLI all resolve through here, so no consumer reads +/// the vault for an account directly (docs/plans/default-token-ux.md). +/// +public sealed class AccountTokenResolver +{ + private readonly ITokenVault _vault; + + + + /// Resolves against . + public AccountTokenResolver(ITokenVault vault) + { + ArgumentNullException.ThrowIfNull(vault); + _vault = vault; + } + + + + /// + /// The token for , or null when its source holds + /// nothing (no default token saved, or the account's own entry is gone). + /// + public string? TryResolve(Account account) + { + ArgumentNullException.ThrowIfNull(account); + string? token = account.TokenSource == TokenSource.Default + ? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId) + : _vault.TryRetrieve(account.Id); + return string.IsNullOrEmpty(token) ? null : token; + } + + + + /// True when the vault holds a default token. + public bool HasDefaultToken => !string.IsNullOrEmpty(_vault.TryRetrieve(AppSettings.DefaultTokenVaultId)); + + + + /// + /// Why returned null for , worded + /// for the user and naming the source, so every "no token" message says what to fix. + /// Never contains a token. + /// + public static string MissingDescription(Account account) + { + ArgumentNullException.ThrowIfNull(account); + return account.TokenSource == TokenSource.Default + ? "uses the default token, but no default token is saved (desktop app: Settings)" + : $"has no token in Windows Credential Manager (entry 'gclo:account:{account.Id:N}')"; + } +} diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs index 69969ee..85f3f77 100644 --- a/gclo.ViewModels/AccountsStore.cs +++ b/gclo.ViewModels/AccountsStore.cs @@ -136,6 +136,17 @@ public sealed class AccountsStore } string verb = index >= 0 ? "updated" : "added"; + if(account.UsesDefaultToken) + { + if(token is not null) + { + throw new ArgumentException( + "An account that uses the default token has no token of its own to store.", nameof(token)); + } + SaveUsingDefaultToken(updated, account, verb); + return; + } + if(token is null) { Persist(updated); // IO failures propagate; _accounts stays unchanged then. @@ -191,6 +202,62 @@ public sealed class AccountsStore + /// + /// Saves an account that resolves to the default token: its metadata is written + /// and any token of its own is removed from the vault, vault first, with the same + /// put-it-back compensation as a delete. Called under the gate. + /// + private void SaveUsingDefaultToken(List updated, Account account, string verb) + { + string? ownToken = _vault.TryRetrieve(account.Id); + if(ownToken is not null) + { + _vault.Delete(account.Id); + } + try + { + Persist(updated); + } + catch(Exception persistFailure) + { + if(ownToken is null) + { + throw; + } + try + { + _vault.Store(account.Id, ownToken); + } + catch(Exception compensationFailure) + { + string leftover = $"account {account.Id:N} ('{account.Name}') lost its own token from the vault while accounts.json " + + "still says it has one; edit it and enter the token again, or switch it to the default token"; + _log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure); + throw new AccountConsistencyException( + $"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring its token " + + $"failed too ({compensationFailure.Message}). Current state: {leftover}.", + persistFailure, compensationFailure); + } + throw; + } + _accounts = updated; + _log.Info($"Account '{account.Name}' {verb}; it uses the default token" + + (ownToken is null ? "." : " (its own token was removed from the vault).")); + } + + + + /// How many accounts resolve to the default token (Settings shows what a removal breaks). + public int CountUsingDefaultToken() + { + lock(_gate) + { + return _accounts.Count(a => a.UsesDefaultToken); + } + } + + + /// /// Removes the account's vault token and then its metadata; an unknown id is a /// no-op. A vault failure changes nothing. If the metadata write fails after the diff --git a/gclo.ViewModels/TokenSource.cs b/gclo.ViewModels/TokenSource.cs new file mode 100644 index 0000000..2ef6da5 --- /dev/null +++ b/gclo.ViewModels/TokenSource.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// Where an account's GitHub token comes from. is the account's +/// own vault entry (keyed by its id); is the default token +/// saved in Settings (), read at use +/// time, so replacing the default token in Settings rotates every such account +/// at once (docs/plans/default-token-ux.md). +/// +public enum TokenSource +{ + /// The account's own token, stored under its id. The on-disk default. + Own = 0, + + /// The default token from Settings, resolved whenever the account is used. + Default = 1, +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index 4dca06c..091fe13 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -140,8 +140,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable TargetFolder = account.TargetRoot; CreateOrgSubfolder = account.CreateOrgSubfolder; MaxConcurrency = account.MaxConcurrency; - // The Token setter's existing org lookup fires naturally with the vault token. - Token = tokenVault?.TryRetrieve(account.Id) ?? ""; + // The Token setter's existing org lookup fires naturally with the resolved + // token: the account's own entry, or the default token from Settings. + Token = tokenVault is null ? "" : new AccountTokenResolver(tokenVault).TryResolve(account) ?? ""; } _constructed = true; } -- 2.54.0