Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 9667f437b7 feat(wizard): offer the default token when adding or editing an account
Step 2 of the account wizard becomes a choice when a default token is saved in
Settings: "Use the default token from Settings" (preselected for a new account,
or when the Quick Sync connection being saved runs on it) or "Use a different
token for this account". The default token is fetched only to validate and is
never loaded into the box, which is disabled while the default is selected.
Editing switches either way: choosing the default says the account's own token
will be removed on save, choosing an own token on a default-token account asks
for one, and a default token removed while the wizard is open gives a
Settings-specific message instead of a generic failure.

The pane tags default-token accounts ("Default token", also in the item's
automation name), and Sync All skips such an account with no default token
saved, naming Settings, before it would hit a generic auth error.

Closes #105
Part of #102

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 17:11:10 -05:00
james.maesandClaude Fable 5.1 c0a99cacd8 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 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 17:03:16 -05:00
james.maesandClaude Fable 5.1 ef86a52bc6 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 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-10 16:44:57 -05:00
james.maesandClaude Fable 5.1 7234c18549 ci(release): re-pin the shared Velopack workflow to the signing fix (#98)
Re-pins elease-velopack.yml to standards 40ecebae (standards#29): signs
only unsigned binaries (48 of 287 instead of all), vpk signing through a
SignTool wrapper instead of its hanging built-in path, and signtool
verify /pa /all over Setup.exe and the portable zip before the draft
release. Reproduced locally end to end against the live signing account
before this change was made (see standards#29).

Closes #97 (part of #62).

Tested: actionlint clean. First CI run: v1.0.2-beta.3.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 16:16:06 -05:00
james.maesandClaude Fable 5.1 2c4726e30e feat(release): Authenticode-sign releases with Azure Artifact Signing over OIDC (#96)
## What

Releases are now Authenticode-signed (K22-REL-05) through the standards'
shared workflow, which already carries the signing steps behind `sign`
(`azure/login` v3.1.0 and `azure/artifact-signing-action` v2.0.0, both
SHA-pinned and current; `vpk pack --azureTrustedSignFile`). ADR 0008.

- `release.yml`: `sign: true`; new `verify-signatures` job after
`release` downloads the published Setup.exe, portable zip and CLI zip
and runs `signtool verify /pa /all` + `Get-AuthenticodeSignature` on
every gclo executable, failing on an invalid, untimestamped or
foreign-publisher signature (`TRUSTED_SIGNING_SUBJECT`).
- Authentication is GitHub OIDC: an Entra application
`gclo-release-signing` with a federated credential for
`repo:KofTwentyTwo/gclo:environment:release` and only *Artifact Signing
Certificate Profile Signer* on the certificate profile. No secret, key
or PFX anywhere. The identity is `release`-environment **variables**:
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID`,
`TRUSTED_SIGNING_ENDPOINT`, `TRUSTED_SIGNING_ACCOUNT`,
`TRUSTED_SIGNING_PROFILE`, `TRUSTED_SIGNING_SUBJECT`.
- `.github/scripts/Set-ReleaseSigning.ps1`: idempotent Azure
configuration (reads the existing account and Active profile through
ARM, ensures app + service principal + federated credential +
profile-scoped role, prints the `gh variable set` lines). Run
interactively by the owner; never creates secrets or touches the
account, validation or profile.
- Docs: RELEASING.md ("Code signing", local signing with SignTool + the
`Microsoft.ArtifactSigning.Client` dlib, the verify job), SECURITY.md,
README verify step 4, threat-model revision row.

Closes #57 (part of #62). After the first signed release verifies,
EX-0002 closes for gclo in the standards register.

## Azure configuration status

Pending the owner's interactive sign-in; the script output and `signtool
verify` results of the first signed prerelease will be posted here
before merge.

## How it was tested

- `actionlint` clean; PSScriptAnalyzer 0 findings.
- Verification of a real signed build: pending the Azure configuration
(recorded here when done).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:39:26 -05:00
james.maesandClaude Fable 5.1 c4d84779ab fix: keep per-user data out of the Velopack install root (#94)
## What

Running any `Setup.exe` over an existing install wiped `accounts.json`,
`accounts.json.bak`, `settings.json` and `logs\` on the maintainer's
machine (#91): Velopack uninstalls the previous version first and
deletes the whole install root, and gclo stored its data in that same
folder, `%LOCALAPPDATA%\gclo`.

- Default data root is now `%LOCALAPPDATA%\KofTwentyTwo\gclo`
(`GcloPaths.DefaultDataRoot`); the CLI's and the activity log's default
follow. `GCLO_DATA_DIR` still overrides it.
- **No migration**, as agreed: single user, single machine, green
fields. The default vault scope is still unscoped, so the existing
`gclo:account:<id>` Credential Manager entries keep working once
accounts are re-added.
- README, `docs/CLI.md`, the two store doc comments and the threat model
(asset table + revision row) say where the data lives and why.
- Tests: default root is the new path and is never under the install
root; same for the log directory.

Closes #91 (blocks #67). Part of #62.

## How it was tested

- `dotnet build -warnaserror` 0 warnings; `dotnet format
--verify-no-changes` clean.
- `gclo.Engine.Tests` 425/425, `gclo.Cli.Tests` 129/129; line coverage
100% on `gclo.Engine` and `gclo.ViewModels`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:30:48 -05:00
james.maesandClaude Fable 5.1 e411c30344 ci: retry PSScriptAnalyzer per file when it throws (#90)
PSScriptAnalyzer 1.25.0 still throws `Object reference not set to an
instance of an object` intermittently even one file per call; it failed
the format gate of the v1.0.2-beta.1 release run (38074357696) until a
re-run. Each file is now analyzed up to three times (2 s pause, a
warning annotation per retry) and only a file that keeps throwing fails
the gate, with the analyzer's error. Findings still fail as before.

Closes #89 (part of #62).

Tested: `actionlint` clean; the loop run locally over the same scripts
reports 0 findings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:16:12 -05:00
james.maesandClaude Fable 5.1 e63b64fb89 fix(release): keep build and publish on one resource configuration; smoke test what ships (#88)
## What

v1.0.1 and the beta.3 rehearsal crash at startup (`0xc000027b` in
`Microsoft.UI.Xaml.dll`). Root cause in #87: the shared release workflow
builds Release (framework-dependent) and then publishes self-contained
into the same `obj`, and the incremental `_GenerateProjectPriFileCore`
target skipped because its new inputs (the Windows App SDK `.pri` files
from the NuGet cache) were older than the index it had already written.
The published `gclo.pri` was 115 KB instead of 2.3 MB and lacked the
WinUI theme resources.

- `gclo/gclo.csproj`: `WindowsPackageType=None` and
`WindowsAppSDKSelfContained=true` unconditionally. gclo ships only
unpackaged and self-contained, so build and publish now share one
resource configuration and the incremental index is always right.
- `release.yml`: new `smoke` job between the gates and the shared
release. It restores, builds Release and publishes with the shared
workflow's exact commands (same incremental `obj` state), then runs the
FlaUI suite against the published `gclo.exe` through `GCLO_UITEST_EXE`.
`release` needs it, so a build that does not start can never be
published again.
- `docs/RELEASING.md`: the smoke job in "Watch the workflow".

Closes #87 (part of #62). Shared-workflow follow-up filed as
KofTwentyTwo/standards#22.

## Done outside this PR

- v1.0.1 marked prerelease with a warning in its notes ("A broken
release" runbook), so stable installs stop receiving it.
- 1.0.0 reinstalled on the maintainer's machine.

## How it was tested

- From clean `bin`/`obj` with the workflow's exact sequence (locked
restore, `dotnet build gclo.slnx -c Release -p:Platform=x64`, the
publish command): before the csproj change `gclo.pri` = 115,816 bytes
and the app exits `0xC000027B`; after it the build and the publish both
give 2,298,032 bytes and the published app opens its main window.
- `actionlint` clean.
- Next: tag `v1.0.2-beta.1`, **install it on a clean machine**, then
`v1.0.2`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 13:04:22 -05:00
james.maesandClaude Fable 5.1 7511d4568d ci(release): open the packaging PR with a release GitHub App token (#86)
## What

`protect-main` requires signed commits, and the `build(packaging): …` PR
the `packages` job opens had an unsigned commit. #82's DCO sign-off was
not enough: `peter-evans/create-pull-request` signs only with a bot
token and explicitly not with a PAT, which is why the v1.0.1 packaging
PR went #80 → #83 (re-created unsigned by "update branch") → #84
(cherry-picked and signed by hand).

- `packages` now mints a short-lived token from a release GitHub App
(`actions/create-github-app-token` v3.2.0, SHA-pinned) using the
repository variable `RELEASE_APP_CLIENT_ID` and the
`release`-environment secret `RELEASE_APP_PRIVATE_KEY` (contents + pull
requests write, this repository only, no bypass), and opens the PR with
it and `sign-commits: true`. GitHub signs the commit, its author is the
App's `[bot]` user (exempt from `pr / dco`), and the PR still triggers
the required checks. Same pattern as the standards repository's own
release automation.
- `DEPS_PAT` is no longer used by the release workflow (it stays
optional for `nuget-updates.yml`). The preflight step fails when the App
id or key is missing.
- `docs/RELEASING.md` (tokens table + "Why a GitHub App" with the
one-time setup), ADR 0003 and the threat model revision table record the
change.

Closes #85 (follows #81, part of #62).

## Owner steps before the next stable release

1. Create a GitHub App (Settings → Developer settings → GitHub Apps):
installed **only on KofTwentyTwo/gclo**, permissions **Contents: write**
and **Pull requests: write**, no ruleset bypass.
2. Repository variable `RELEASE_APP_CLIENT_ID` = the App's client id.
3. `release` environment secret `RELEASE_APP_PRIVATE_KEY` = the App's
private key (`.pem`), e.g. `op read "op://<vault>/<item>/private key" |
gh secret set RELEASE_APP_PRIVATE_KEY --env release --repo
KofTwentyTwo/gclo`.

## How it was tested

- `actionlint` clean; `create-github-app-token@bcd2ba4` is the `v3.2.0`
tag and exposes `client-id`, `private-key`, `permission-contents`,
`permission-pull-requests`.
- Not exercised on a tag: the next stable release is the first run. If
the App is not set up by then, the `packages` job fails at its preflight
and can be re-run alone after the secrets are added.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 12:24:57 -05:00
james.maes 34b6870f3d build(packaging): point the Scoop and Chocolatey manifests at v1.0.1 (#84)
Generated by the Release workflow for tag `v1.0.1`.

- `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at
  `gclo-cli-win-x64-1.0.1.zip`
(SHA-256
`641ae4100022fba3fe489f8abe6ccc43186c070087d3edbf3a7891d5dc7428f7`).
- `scoop update gclo` picks this up as soon as this PR is merged (Scoop
  reads the manifest from `main`); the Chocolatey package was pushed to
  chocolatey.org.

Relates to #9.

Signed-off-by: James Maes <james@kof22.com>
2026-10-10 12:14:15 -05:00
james.maesandClaude Fable 5.1 f2b3900490 ci(release): sign off the generated packaging commit (#82)
## What

The `packages` job's `build(packaging): …` pull request is generated by
`peter-evans/create-pull-request`, and its commit carried no
`Signed-off-by:` trailer, so the standards' `pr / dco` gate (which needs
a sign-off matching the commit author) failed on the first real one, #80
for v1.0.1. The action now sets `author` and `committer` to the same
identity (the actor who pushed the tag,
`<id>+<login>@users.noreply.github.com`) and `signoff: true`, so the
trailer it adds matches the author the check compares against.

Closes #81 (part of #62). #80 itself needs its commit amended with a
sign-off (or an admin merge); this PR fixes every later release.

## How it was tested

- `actionlint` clean.
- The three inputs exist on the pinned `create-pull-request` v8.1.1
(checked `action.yml` at the SHA).
- Not exercised on a tag: the next stable release is the first run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: James Maes <james@kof22.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 11:45:51 -05:00
44 changed files with 2104 additions and 129 deletions

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Configures the Azure side of gclo's release signing (ADR 0008) and prints the
GitHub `release`-environment variables the workflow needs.
.DESCRIPTION
Idempotent. Run interactively by the owner after `Connect-AzAccount` into the
KofTwentyTwo tenant (Az.Accounts, Az.Resources and Az.CodeSigning modules).
It never creates a secret, key or certificate and never touches the signing
account, the publisher validation or the certificate profile themselves:
1. Reads the Artifact Signing account and its certificate profile and fails if
either is missing or the profile is not Active.
2. Ensures an Entra application + service principal for this repository.
3. Ensures a federated credential on it that trusts GitHub's OIDC issuer for
subject `repo:<owner/repo>:environment:<environment>` only.
4. Ensures the service principal holds "Artifact Signing Certificate Profile
Signer" scoped to the certificate profile, and nothing broader.
5. Prints the variable values to set with `gh variable set --env release`.
.PARAMETER Repository
The GitHub repository the credential is bound to (owner/name).
.PARAMETER Environment
The GitHub environment whose jobs may sign (its deployment rule limits it to
v* tags).
.PARAMETER ResourceGroup
Resource group that holds the signing account.
.PARAMETER AccountName
The Artifact Signing account name.
.PARAMETER ProfileName
The certificate profile name on that account.
.PARAMETER ApplicationName
Display name of the Entra application that represents this repository's
release job.
.EXAMPLE
Connect-AzAccount -Tenant koftwentytwooutlook.onmicrosoft.com
./.github/scripts/Set-ReleaseSigning.ps1
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Repository = 'KofTwentyTwo/gclo',
[string] $Environment = 'release',
[string] $ResourceGroup = 'rg-code-signing',
[string] $AccountName = 'kof22signing',
[string] $ProfileName = 'releases',
[string] $ApplicationName = 'gclo-release-signing'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$signerRole = 'Artifact Signing Certificate Profile Signer'
$issuer = 'https://token.actions.githubusercontent.com'
$subject = "repo:$Repository`:environment:$Environment"
$context = Get-AzContext
if($null -eq $context)
{
throw 'Not signed in. Run Connect-AzAccount into the KofTwentyTwo tenant first.'
}
Write-Output "Tenant $($context.Tenant.Id), subscription '$($context.Subscription.Name)' ($($context.Subscription.Id))"
# 1. The signing account and profile must already exist; this script never creates them.
# Az.CodeSigning has no account/profile cmdlets, so this reads the ARM resources.
$apiVersion = '2024-09-30-preview'
$accountPath = "/subscriptions/$($context.Subscription.Id)/resourceGroups/$ResourceGroup/providers/Microsoft.CodeSigning/codeSigningAccounts/$AccountName"
$accountResponse = Invoke-AzRestMethod -Method GET -Path "$accountPath`?api-version=$apiVersion"
if($accountResponse.StatusCode -ne 200)
{
throw "Signing account '$AccountName' not found in '$ResourceGroup' (HTTP $($accountResponse.StatusCode)): $($accountResponse.Content)"
}
$account = $accountResponse.Content | ConvertFrom-Json
$certProfileResponse = Invoke-AzRestMethod -Method GET -Path "$accountPath/certificateProfiles/$ProfileName`?api-version=$apiVersion"
if($certProfileResponse.StatusCode -ne 200)
{
throw "Certificate profile '$ProfileName' not found on '$AccountName' (HTTP $($certProfileResponse.StatusCode)): $($certProfileResponse.Content)"
}
$certProfile = $certProfileResponse.Content | ConvertFrom-Json
if($certProfile.properties.status -ne 'Active')
{
throw "Certificate profile '$ProfileName' is '$($certProfile.properties.status)', not Active."
}
$endpoint = $account.properties.accountUri.TrimEnd('/')
# The validated publisher is on the issued certificate (subjectName), not on the profile.
$certificate = $certProfile.properties.certificates | Where-Object { $_.status -eq 'Active' } | Select-Object -First 1
if($null -eq $certificate)
{
throw "Certificate profile '$ProfileName' has no active certificate."
}
$subjectName = (($certificate.subjectName -split ',\s*') | Where-Object { $_ -like 'CN=*' } | Select-Object -First 1) -replace '^CN='
Write-Output "Signing account '$AccountName' in $($account.location): endpoint $endpoint."
Write-Output "Profile '$ProfileName' is Active (type $($certProfile.properties.profileType)); certificate subject CN='$subjectName'."
# 2. One application per repository, so the trust can be revoked per repository.
$app = Get-AzADApplication -DisplayName $ApplicationName | Select-Object -First 1
if($null -eq $app)
{
if($PSCmdlet.ShouldProcess($ApplicationName, 'Create Entra application'))
{
$app = New-AzADApplication -DisplayName $ApplicationName -SignInAudience AzureADMyOrg
}
}
$sp = Get-AzADServicePrincipal -ApplicationId $app.AppId
if($null -eq $sp)
{
if($PSCmdlet.ShouldProcess($ApplicationName, 'Create service principal'))
{
$sp = New-AzADServicePrincipal -ApplicationId $app.AppId
}
}
Write-Output "Application '$ApplicationName': client id $($app.AppId), service principal $($sp.Id)."
# 3. Federated credential: GitHub OIDC, this repository's release environment only.
$credentialName = "github-$($Repository.Replace('/', '-'))-$Environment"
$existing = Get-AzADAppFederatedCredential -ApplicationObjectId $app.Id | Where-Object { $_.Subject -eq $subject -and $_.Issuer -eq $issuer }
if($null -eq $existing)
{
if($PSCmdlet.ShouldProcess($credentialName, "Create federated credential for $subject"))
{
New-AzADAppFederatedCredential -ApplicationObjectId $app.Id -Name $credentialName -Issuer $issuer -Subject $subject -Audience 'api://AzureADTokenExchange' | Out-Null
}
}
Write-Output "Federated credential trusts $issuer for subject '$subject'."
# 4. Signer role on the certificate profile only. Owner/Contributor grant no signing right.
$profileScope = $certProfile.Id
$assignment = Get-AzRoleAssignment -ObjectId $sp.Id -Scope $profileScope -RoleDefinitionName $signerRole -ErrorAction SilentlyContinue
if($null -eq $assignment)
{
if($PSCmdlet.ShouldProcess($profileScope, "Assign '$signerRole'"))
{
New-AzRoleAssignment -ObjectId $sp.Id -Scope $profileScope -RoleDefinitionName $signerRole | Out-Null
}
}
$broader = Get-AzRoleAssignment -ObjectId $sp.Id | Where-Object { $_.Scope -ne $profileScope }
if($broader)
{
Write-Warning "The service principal also holds: $(($broader | ForEach-Object { "$($_.RoleDefinitionName) @ $($_.Scope)" }) -join '; '). Remove anything it does not need."
}
Write-Output "'$signerRole' is assigned at $profileScope."
# 5. The values the workflow reads (none of them is a secret).
Write-Output ''
Write-Output "gh variable set AZURE_TENANT_ID --env $Environment --repo $Repository --body '$($context.Tenant.Id)'"
Write-Output "gh variable set AZURE_CLIENT_ID --env $Environment --repo $Repository --body '$($app.AppId)'"
Write-Output "gh variable set AZURE_SUBSCRIPTION_ID --env $Environment --repo $Repository --body '$($context.Subscription.Id)'"
Write-Output "gh variable set TRUSTED_SIGNING_ENDPOINT --env $Environment --repo $Repository --body '$endpoint'"
Write-Output "gh variable set TRUSTED_SIGNING_ACCOUNT --env $Environment --repo $Repository --body '$AccountName'"
Write-Output "gh variable set TRUSTED_SIGNING_PROFILE --env $Environment --repo $Repository --body '$ProfileName'"
Write-Output "gh variable set TRUSTED_SIGNING_SUBJECT --env $Environment --repo $Repository --body '$subjectName'"
+21 -4
View File
@@ -169,13 +169,30 @@ jobs:
shell: pwsh
run: |
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
# One file per call: PSScriptAnalyzer 1.25.0 intermittently fails with
# "Object reference not set to an instance of an object" when it is
# handed a directory, and never when it is handed a single script.
# One file per call, three attempts each: PSScriptAnalyzer 1.25.0
# intermittently throws "Object reference not set to an instance of an
# object" from inside the analyzer (#89); a retry has always passed. A
# file that throws three times fails the gate with the analyzer's error.
$settings = (Resolve-Path ./PSScriptAnalyzerSettings.psd1).Path
$scripts = Get-ChildItem -Path .github/scripts, packaging/chocolatey/tools -Recurse -Include *.ps1, *.psm1, *.psd1
$findings = @()
foreach ($script in $scripts) { $findings += @(Invoke-ScriptAnalyzer -Path $script.FullName -Settings $settings) }
foreach ($script in $scripts)
{
for ($attempt = 1; $attempt -le 3; $attempt++)
{
try
{
$findings += @(Invoke-ScriptAnalyzer -Path $script.FullName -Settings $settings -ErrorAction Stop)
break
}
catch
{
if ($attempt -eq 3) { throw }
Write-Output "::warning file=$($script.FullName)::PSScriptAnalyzer threw on attempt $attempt ($($_.Exception.Message)); retrying."
Start-Sleep -Seconds 2
}
}
}
foreach ($f in $findings) { Write-Output "::error file=$($f.ScriptPath),line=$($f.Line)::$($f.RuleName): $($f.Message)" }
if ($findings.Count -gt 0) { exit 1 }
Write-Output 'PSScriptAnalyzer: no findings.'
+155 -11
View File
@@ -24,14 +24,17 @@
# published stable installer, so the job runs once the WINGET_PACKAGE_ID variable
# is set (docs/RELEASING.md) and fails if the token is missing from then on. The
# tokens live in the `release` environment, which only v* tags can use:
# DEPS_PAT fine-grained token: contents + pull requests (write) on this
# repository, so the packaging PR runs the required checks
# RELEASE_APP_PRIVATE_KEY private key of the release GitHub App (contents +
# pull requests write on this repository only); the job mints a
# short-lived token from it, so the packaging PR's commit is
# signed by GitHub and the PR runs the required checks
# CHOCO_API_KEY chocolatey.org API key
# WINGET_TOKEN fine-grained token that can fork and open pull requests on
# public repositories (microsoft/winget-pkgs)
# NUGET_API_KEY nuget.org key with push rights for gclo.Engine (NUGET_PUBLISH)
# plus the repository variable WINGET_PACKAGE_ID (KofTwentyTwo.gclo, set after the
# one-time manual `wingetcreate new`).
# plus the repository variables RELEASE_APP_CLIENT_ID (the release App's client id)
# and WINGET_PACKAGE_ID (KofTwentyTwo.gclo, set after the one-time manual
# `wingetcreate new`).
name: Release
on:
@@ -84,10 +87,65 @@ jobs:
permissions:
contents: read
# The gates test what `dotnet build` produces; this job tests what ships. It
# publishes the app exactly as the shared workflow does (same restore, build
# and publish commands, so the same incremental obj state) and runs the FlaUI
# suite against that published gclo.exe. v1.0.1 passed every gate and crashed
# at startup because its published resource index was stale (#87); nothing is
# published until this passes.
smoke:
name: Smoke test the published app
needs: verify
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# No NuGet cache: a release workflow must not consume runtime-generated
# caches (zizmor cache-poisoning); the locked restore is deterministic.
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Restore (locked)
run: dotnet restore gclo.slnx -p:Platform=x64 --locked-mode
- name: Build Release (as the shared workflow does before publishing)
run: dotnet build gclo.slnx -c Release -p:Platform=x64 -warnaserror --no-restore
- name: Publish app (the shared workflow's exact command)
env:
TAG: ${{ github.ref_name }}
run: >-
$version = $env:TAG.Substring(1);
dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained
"-p:Version=$version" -p:RestoreLockedMode=true -p:WindowsPackageType=None
-p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app
- name: Run the UI suite against the published exe
env:
GCLO_UITEST_EXE: ${{ github.workspace }}\publish\app\gclo.exe
GCLO_UITEST_ARTIFACTS: ${{ github.workspace }}\TestResults\smoke
run: dotnet test gclo.UiTests/gclo.UiTests.csproj --logger "trx;LogFileName=smoke.trx" --results-directory TestResults
- name: Upload smoke test diagnostics
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: smoke-test-diagnostics
path: TestResults
if-no-files-found: warn
release:
name: Release
needs: [verify, gates]
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@e8ab30f8c60df437440ffd786c87678207246f5c # main 2026-10-09 (pre-1.0 draft, standards#5)
needs: [verify, gates, smoke]
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@40ecebae5c32d5550afb4af157eee69572bd267a # main 2026-10-10 (signs only unsigned binaries, standards#29)
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations; Azure OIDC when signing
@@ -106,7 +164,77 @@ jobs:
gclo.Engine.Tests
gclo.Cli.Tests
cli-project: gclo.Cli/gclo.Cli.csproj
sign: false # standards exception EX-0002 (#57): true once the Azure Trusted Signing identity is validated
# Authenticode (Azure Artifact Signing, formerly Trusted Signing) over OIDC: the
# shared workflow signs every published exe/dll and vpk signs Setup.exe and the
# update stub with the same identity. The identity lives in the `release`
# environment's variables (AZURE_CLIENT_ID, AZURE_TENANT_ID,
# AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT, TRUSTED_SIGNING_ACCOUNT,
# TRUSTED_SIGNING_PROFILE, docs/RELEASING.md) and is granted only to this
# repository's v* tags through a federated credential (#57, closes EX-0002).
sign: true
# The shipped artifacts carry valid Authenticode signatures: every exe inside the
# installer, the portable zip and the CLI zip, and Setup.exe itself, verify with
# `signtool verify /pa /all` and show the publisher the certificate profile was
# validated for. Runs on what was actually published, so a signing step that
# silently produced unsigned files fails the run (and the release is then
# handled per "A broken release" in docs/RELEASING.md), not a later user.
verify-signatures:
name: Verify signatures on the published assets
needs: release
runs-on: windows-latest
timeout-minutes: 15
permissions:
contents: read
env:
TAG: ${{ github.ref_name }}
K22_VERSION: ${{ needs.release.outputs.version }}
K22_CHANNEL: ${{ needs.release.outputs.prerelease == 'true' && 'dev' || 'stable' }}
EXPECTED_SUBJECT: ${{ vars.TRUSTED_SIGNING_SUBJECT }}
steps:
- name: Download the published assets
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
New-Item -ItemType Directory -Force assets | Out-Null
gh release download $env:TAG --dir assets --pattern "gclo-$env:K22_CHANNEL-Setup.exe" --pattern "gclo-$env:K22_CHANNEL-Portable.zip" --pattern "gclo-cli-win-x64-$env:K22_VERSION.zip"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Expand-Archive assets/gclo-$env:K22_CHANNEL-Portable.zip -DestinationPath assets/portable
Expand-Archive assets/gclo-cli-win-x64-$env:K22_VERSION.zip -DestinationPath assets/cli
- name: signtool verify /pa /all on every shipped executable
shell: pwsh
run: |
if ([string]::IsNullOrEmpty($env:EXPECTED_SUBJECT))
{
Write-Output '::error::TRUSTED_SIGNING_SUBJECT is not set in the release environment (docs/RELEASING.md).'
exit 1
}
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
$files = @(Get-Item assets/gclo-$env:K22_CHANNEL-Setup.exe) + @(Get-ChildItem assets/portable, assets/cli -Recurse -Include *.exe, *.dll |
Where-Object { $_.Name -like 'gclo*' -or $_.Name -in 'Update.exe' })
$failed = 0
foreach ($f in $files)
{
& $signtool.FullName verify /pa /all /v $f.FullName | Out-Null
$sig = Get-AuthenticodeSignature -LiteralPath $f.FullName
$subject = $sig.SignerCertificate.Subject
$timestamped = $null -ne $sig.TimeStamperCertificate
if ($LASTEXITCODE -ne 0 -or $sig.Status -ne 'Valid' -or -not $timestamped -or $subject -notlike "*$env:EXPECTED_SUBJECT*")
{
Write-Output "::error file=$($f.Name)::signature status '$($sig.Status)', timestamped=$timestamped, subject '$subject' (expected '*$env:EXPECTED_SUBJECT*', signtool exit $LASTEXITCODE)"
$failed++
}
else
{
Write-Output "ok $($f.Name) $subject (timestamped)"
}
}
if ($failed -gt 0) { exit 1 }
Write-Output "All $($files.Count) shipped executables carry a valid, timestamped signature by '$env:EXPECTED_SUBJECT'."
# Scoop and Chocolatey (#9), stable releases only. The repository is its own
# Scoop bucket (bucket/gclo.json) and ships the Chocolatey package source
@@ -136,13 +264,14 @@ jobs:
- name: Require the publishing secrets
shell: pwsh
env:
DEPS_PAT: ${{ secrets.DEPS_PAT }}
RELEASE_APP_CLIENT_ID: ${{ vars.RELEASE_APP_CLIENT_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
run: |
$missing = @('DEPS_PAT', 'CHOCO_API_KEY') | Where-Object { [string]::IsNullOrEmpty((Get-Item -Path "env:$_" -ErrorAction SilentlyContinue).Value) }
$missing = @('RELEASE_APP_CLIENT_ID', 'RELEASE_APP_PRIVATE_KEY', 'CHOCO_API_KEY') | Where-Object { [string]::IsNullOrEmpty((Get-Item -Path "env:$_" -ErrorAction SilentlyContinue).Value) }
if ($missing)
{
Write-Output "::error::Missing in the release environment: $($missing -join ', '). Add them (docs/RELEASING.md) and re-run this job."
Write-Output "::error::Missing in the release environment (or repository variables): $($missing -join ', '). Add them (docs/RELEASING.md) and re-run this job."
exit 1
}
@@ -173,10 +302,25 @@ jobs:
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
run: choco push (Get-ChildItem artifacts/chocolatey/*.nupkg | Select-Object -First 1).FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
# protect-main requires signed commits and a DCO sign-off. The action only
# signs with a bot token (a PAT is explicitly unsupported), so the PR is
# opened with a short-lived token of the release GitHub App: GitHub signs
# the commit, its author is the App's [bot] user (exempt from the DCO
# check), and the PR still triggers the required checks (#81, #85).
- name: Mint a release App token
id: app
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Open a PR with the updated package manifests
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.DEPS_PAT }}
token: ${{ steps.app.outputs.token }}
sign-commits: true
base: main
branch: packaging/v${{ needs.release.outputs.version }}
delete-branch: true
+8 -2
View File
@@ -62,9 +62,15 @@ gh attestation verify .\gclo-stable-Setup.exe --repo KofTwentyTwo/gclo --signer-
# 3. Dependencies: the SBOM is attested too
gh attestation verify .\gclo-cli-win-x64-<version>.zip --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards --predicate-type https://cyclonedx.org/bom
# 4. Publisher: Setup.exe and every gclo executable are Authenticode-signed (Azure
# Artifact Signing, SHA-256, RFC 3161 timestamp) by KofTwentyTwo
signtool verify /pa /all /v .\gclo-stable-Setup.exe
Get-AuthenticodeSignature .\gclo-stable-Setup.exe | Format-List Status, SignerCertificate, TimeStamperCertificate
```
The binaries are not yet Authenticode-signed (standards exception [EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002), [#57](https://github.com/KofTwentyTwo/gclo/issues/57)), so step 2 is the origin check until `Get-AuthenticodeSignature` can be.
Windows checks step 4 for you: a signed installer carries the publisher name in the
SmartScreen and UAC prompts instead of "Unknown publisher".
## Getting a token
@@ -110,7 +116,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
**Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration.
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\gclo\logs`, one file per day; they record run parameters and per-repo failures and never contain your token (see [SECURITY.md](SECURITY.md)).
**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\KofTwentyTwo\gclo\logs`, one file per day; they record run parameters and per-repo failures and never contain your token (see [SECURITY.md](SECURITY.md)).
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel. Self-update is only available in installed builds (not the portable zip or a local debug build).
+7 -5
View File
@@ -110,8 +110,10 @@ is a vulnerability: please report it through the channel above.
[README](README.md#verifying-a-release).
- Self-update downloads only from this repository's GitHub Releases and verifies each
package against the release's `releases.<channel>.json` feed.
- The binaries are **not yet Authenticode-signed**: KofTwentyTwo standards exception
[EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002)
(tracked here as [#57](https://github.com/KofTwentyTwo/gclo/issues/57)). Until they
are, expect a SmartScreen prompt on first install, and prefer the checksum and
attestation checks over trusting a download by its name.
- Setup.exe, the update stub and every gclo executable and library are
**Authenticode-signed** with Azure Artifact Signing (SHA-256, RFC 3161 timestamp) in
the release workflow, over OIDC with an identity that only this repository's release
tags can use; no signing key or secret exists outside Azure. The workflow verifies
the published assets with `signtool verify /pa /all` and fails if any shipped
executable is unsigned or signed by another publisher. Check the publisher yourself
with `Get-AuthenticodeSignature` (README, "Verifying a release").
+25 -25
View File
@@ -1,28 +1,28 @@
{
"version": "1.0.0",
"description": "Git Clone Large Organizations: clone or update every repository of a GitHub organization or user account, in parallel (command-line edition)",
"homepage": "https://github.com/KofTwentyTwo/gclo",
"license": "MIT",
"notes": [
"gclo needs a GitHub personal access token: set GITHUB_TOKEN, or pass --token-env, --token-file, or --token-stdin.",
"The desktop app is a separate download: https://github.com/KofTwentyTwo/gclo/releases"
],
"architecture": {
"64bit": {
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip",
"hash": "42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee"
}
},
"bin": "gclo.exe",
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64-$version.zip",
"hash": {
"url": "$baseurl/SHA256SUMS"
}
}
}
"version": "1.0.1",
"description": "Git Clone Large Organizations: clone or update every repository of a GitHub organization or user account, in parallel (command-line edition)",
"homepage": "https://github.com/KofTwentyTwo/gclo",
"license": "MIT",
"notes": [
"gclo needs a GitHub personal access token: set GITHUB_TOKEN, or pass --token-env, --token-file, or --token-stdin.",
"The desktop app is a separate download: https://github.com/KofTwentyTwo/gclo/releases"
],
"architecture": {
"64bit": {
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.1/gclo-cli-win-x64-1.0.1.zip",
"hash": "641ae4100022fba3fe489f8abe6ccc43186c070087d3edbf3a7891d5dc7428f7"
}
},
"bin": "gclo.exe",
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64-$version.zip",
"hash": {
"url": "$baseurl/SHA256SUMS"
}
}
}
}
}
+25 -9
View File
@@ -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>`).
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
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`
+80 -4
View File
@@ -64,13 +64,80 @@ its first submission cannot be automated; see below.
| Job | Runs for | Needs | Publishes |
| --- | -------- | ----- | --------- |
| `release` (shared workflow) | every tag | nothing beyond `GITHUB_TOKEN` | the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, `SHA256SUMS`, provenance and SBOM attestations |
| `packages` | stable only | `DEPS_PAT` (fine-grained token: contents and pull requests, write, this repository, ≤ 90 days), `CHOCO_API_KEY` (chocolatey.org) | the Chocolatey package pushed to chocolatey.org, and a `build(packaging): …` pull request against `main` with `bucket/gclo.json` and `packaging/chocolatey` pointed at the release |
| `release` (shared workflow) | every tag | the signing identity, as `release`-environment **variables** (no secret exists): `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID`, `TRUSTED_SIGNING_ENDPOINT`, `TRUSTED_SIGNING_ACCOUNT`, `TRUSTED_SIGNING_PROFILE` (see "Code signing") | the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, `SHA256SUMS`, provenance and SBOM attestations; every exe/dll and Setup.exe Authenticode-signed |
| `verify-signatures` | every tag | `TRUSTED_SIGNING_SUBJECT` (`release`-environment variable: the publisher name on the certificate, as validated by Azure) | nothing; fails the run when a shipped executable is unsigned, untimestamped or signed by another publisher |
| `packages` | stable only | `RELEASE_APP_PRIVATE_KEY` (private key of the release GitHub App, see below) plus the repository variable `RELEASE_APP_CLIENT_ID`, `CHOCO_API_KEY` (chocolatey.org) | the Chocolatey package pushed to chocolatey.org, and a `build(packaging): …` pull request against `main` with `bucket/gclo.json` and `packaging/chocolatey` pointed at the release |
| `winget` | stable only, once the repository variable `WINGET_PACKAGE_ID` is set (`KofTwentyTwo.gclo`, after the one-time manual submission below) | `WINGET_TOKEN` (fine-grained token that can fork and open pull requests on public repositories, ≤ 90 days); missing → the job fails | a manifest update PR on [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) |
| `nuget` | only when the repository variable `NUGET_PUBLISH` is `true` | `NUGET_API_KEY` (nuget.org, push rights for `gclo.Engine`) | `gclo.Engine` on nuget.org |
Why `DEPS_PAT`: a pull request opened with the default `GITHUB_TOKEN` triggers no
status checks, so the packaging PR could never satisfy `main`'s required checks.
Why a GitHub App: `main` requires signed commits, a DCO sign-off, and the full set
of status checks. A pull request opened with the default `GITHUB_TOKEN` triggers
no checks, and the action that opens the PR cannot sign commits with a personal
access token. With a short-lived token minted from the App, GitHub signs the
commit, its author is the App's `[bot]` user (exempt from the DCO check), and the
checks run. Create the App once (the same pattern the standards repository uses):
GitHub App installed **only on this repository**, permissions Contents and Pull
requests **write**, no ruleset bypass. Store its client id as the Actions variable
`RELEASE_APP_CLIENT_ID` and its private key (`.pem`) as `RELEASE_APP_PRIVATE_KEY`
in the `release` environment; never as a repository secret or a committed file.
## Code signing
Every release is Authenticode-signed with **Azure Artifact Signing** (formerly Trusted
Signing): the shared workflow signs every published `.exe`/`.dll` (app and CLI) before
packing, and `vpk pack` signs `Setup.exe` and the update stub with the same identity
(`--azureTrustedSignFile`). Always SHA-256 with an RFC 3161 timestamp from
`http://timestamp.acs.microsoft.com`. `SHA256SUMS`, the Velopack feed and the
attestations are produced after signing, so they describe the signed files. The
`verify-signatures` job then downloads what was published and runs
`signtool verify /pa /all` on Setup.exe and every gclo executable (ADR
[0008](adr/0008-azure-artifact-signing.md)).
There is **no signing secret**: the release job logs in to Azure over GitHub OIDC
(`azure/login`) as an Entra application whose federated credential trusts only this
repository's `release` environment, and that application holds only the
*Artifact Signing Certificate Profile Signer* role on the certificate profile.
The identity is configuration, not a secret, so it lives in `release`-environment
variables:
| Variable | Value |
| --- | --- |
| `AZURE_TENANT_ID` | the Entra tenant GUID (not the directory domain) |
| `AZURE_CLIENT_ID` | the application (client) id of `gclo-release-signing` |
| `AZURE_SUBSCRIPTION_ID` | the subscription that holds the signing account |
| `TRUSTED_SIGNING_ENDPOINT` | the account's regional endpoint, e.g. `https://eus.codesigning.azure.net` |
| `TRUSTED_SIGNING_ACCOUNT` | the signing account name (`kof22signing`) |
| `TRUSTED_SIGNING_PROFILE` | the certificate profile name (`releases`) |
| `TRUSTED_SIGNING_SUBJECT` | the validated publisher name on the certificate, checked by `verify-signatures` |
A missing variable fails the Azure login, and the run with it; nothing unsigned is
published. The Azure side (application, federated credential, role assignment) is
administered interactively by the owner; `.github/scripts/Set-ReleaseSigning.ps1`
records the exact steps and is idempotent.
### Signing locally
Only for a one-off (for example re-signing a hotfix build by hand); releases are
signed in CI. You need SignTool (Windows SDK), the .NET 8 runtime, the
[Microsoft.ArtifactSigning.Client](https://www.nuget.org/packages/Microsoft.ArtifactSigning.Client)
package (the `dlib`), and an identity holding the *Certificate Profile Signer* role:
```powershell
# One-time: the dlib, and a metadata file with the same values as the variables above
nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory $env:LOCALAPPDATA\ArtifactSigning
@{ Endpoint = 'https://eus.codesigning.azure.net'; CodeSigningAccountName = 'kof22signing'; CertificateProfileName = 'releases' } |
ConvertTo-Json | Set-Content $env:LOCALAPPDATA\ArtifactSigning\metadata.json
# Sign in as an authorized identity (the dlib uses the Azure CLI / Azure PowerShell credential)
az login --tenant <tenant GUID>
# Sign and verify
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object FullName -match '\\x64\\' | Sort-Object FullName -Descending | Select-Object -First 1
& $signtool sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 `
/dlib "$env:LOCALAPPDATA\ArtifactSigning\Microsoft.ArtifactSigning.Client\bin\x64\Azure.CodeSigning.Dlib.dll" `
/dmdf "$env:LOCALAPPDATA\ArtifactSigning\metadata.json" .\gclo.exe
& $signtool verify /pa /all /v .\gclo.exe
```
## First winget submission (one-time, manual)
@@ -148,11 +215,20 @@ Both install the same asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-25
- **Gates** — the full `ci` workflow, exactly as a pull request runs it,
including the FlaUI end-to-end suite. If a gate fails, fix the cause on
`main` and cut a new patch version; never reuse the tag.
- **Smoke test the published app** — publishes the app with the shared
workflow's exact commands and runs the FlaUI suite against that published
`gclo.exe`. The gates test what `dotnet build` produces; this tests what
ships (1.0.1 passed every gate and crashed at startup, #87). Nothing is
published until it passes.
- **Release** (shared workflow): locked restore, Release build with warnings
as errors, tests with the 100% coverage gate, publish, Velopack pack, SBOMs,
`SHA256SUMS`, provenance, a **draft** GitHub Release with every asset and
generated notes, then **publish** — releases are immutable, so everything
lands while the release is still a draft.
- **Verify signatures on the published assets** — `signtool verify /pa /all`
on Setup.exe and every gclo executable inside the portable and CLI zips,
checking the publisher name and the timestamp. A failure here means a
published release is unsigned: handle it as "A broken release".
- **Scoop and Chocolatey**, **winget**, **NuGet** — the publishers described
above; each fails loudly when it cannot publish.
+3 -1
View File
@@ -47,7 +47,9 @@ and hash; `PackagingManifestTests` keeps both manifests valid and in agreement.
- Stable releases are consumable via `scoop bucket add gclo <repo>` with no external
account; prereleases never touch package managers.
- The generated manifest pull request targets `main` and goes through the normal
gates (with `DEPS_PAT` it also gets status checks).
gates: it is opened with a short-lived release GitHub App token, so its commit
is signed by GitHub, exempt from the DCO check as a bot, and runs the required
status checks (#85).
- Security: a new distribution channel; both channels verify the asset hash
(threat model T2), and the hash they pin is the one `SHA256SUMS` attests.
- Follow-up: `CHOCO_API_KEY` and the first Chocolatey moderation (#7).
+60
View File
@@ -0,0 +1,60 @@
# 0008. Authenticode-sign releases with Azure Artifact Signing over GitHub OIDC
- Status: accepted
- Date: 2026-10-10
- Supersedes: KofTwentyTwo standards exception EX-0002 for this repository
## Context
K22-REL-05 requires Windows executables and installers to be Authenticode-signed.
Until now gclo shipped unsigned (exception EX-0002, #57): users saw "Unknown
publisher" in SmartScreen and UAC, and the only origin check was the build-provenance
attestation, which proves which workflow built a file, not who publishes it.
KofTwentyTwo now has an Azure Artifact Signing (formerly Trusted Signing) account
with a completed publisher identity validation and an active Public Trust
certificate profile. The standards' shared `release-velopack.yml` already carries
the signing steps behind its `sign` input: `azure/login` over OIDC, then
`azure/artifact-signing-action` over every published `.exe`/`.dll`, then `vpk pack
--azureTrustedSignFile` so Setup.exe and the update stub are signed with the same
identity.
## Decision
- Turn the shared workflow's `sign` input on. Signing happens after publish and
before packing; `SHA256SUMS`, the Velopack feed and the attestations are
computed over the signed files. SHA-256 digests and RFC 3161 timestamps
(`http://timestamp.acs.microsoft.com`) always.
- Authenticate with **GitHub OIDC**, never a secret: an Entra application
(`gclo-release-signing`) with a federated credential whose subject is
`repo:KofTwentyTwo/gclo:environment:release`, so only a job running in the
tag-restricted `release` environment can obtain a token. The application holds
exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the
certificate profile. Owner or Contributor on the subscription grants no signing
right and is not given.
- The identity is configuration, not a secret: tenant, client and subscription ids,
endpoint, account and profile names are `release`-environment variables. Missing
values fail the Azure login, and nothing unsigned is published.
- A caller-side `verify-signatures` job downloads the published Setup.exe, portable
and CLI zips and runs `signtool verify /pa /all` plus `Get-AuthenticodeSignature`
on every gclo executable, checking status, timestamp and the validated publisher
name (`TRUSTED_SIGNING_SUBJECT`). It cannot stop a publish that already happened,
but it turns a silently unsigned release into a failed run handled by the broken-
release runbook; the shared workflow is asked to verify before publishing
(standards#22 follow-up).
- Local signing is documented for one-off needs only (SignTool + the
`Microsoft.ArtifactSigning.Client` dlib + an authorized Azure sign-in); releases
are signed in CI.
- The Azure configuration is scripted in `.github/scripts/Set-ReleaseSigning.ps1`
(idempotent, run interactively by the owner) so it can be audited and repeated.
## Consequences
- SmartScreen and UAC show the validated publisher; Velopack's installer and update
stub are signed, so silent self-update no longer rests on TLS plus the GitHub
account alone.
- Releases depend on Azure being reachable and the certificate profile being
active; a signing outage fails the release rather than shipping unsigned.
- No PFX, private key or client secret exists anywhere; nothing to rotate except
the federated credential if the repository or environment is renamed.
- EX-0002 closes for gclo once the first signed release verifies.
+174
View File
@@ -0,0 +1,174 @@
# UX plan: the default token and account tokens
> **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
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 <value>`; `--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.
+6 -2
View File
@@ -26,7 +26,7 @@ GitHub.
| GitHub REST API | Lists organizations and repositories | Untrusted input (responses are parsed), authenticated by the PAT |
| Git remotes (`github.com`) | Clone and fetch over HTTPS via libgit2 | Untrusted input (repository content, including file paths) |
| Windows Credential Manager | Stores saved-account and default tokens | Trusted store; boundary is the Windows user account |
| Local data directory | `%LOCALAPPDATA%\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs | Trusted; same user |
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
| WSL default distribution | Runs `git clone` for the "Clone in WSL" recovery | Semi-trusted (user-installed Linux environment) |
| Update feed | GitHub Releases of this repository, `releases.<channel>.json` | Untrusted until verified (HTTPS, package hashes, attestations) |
@@ -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 |
@@ -111,3 +111,7 @@ flowchart LR
| --- | --- | --- | --- |
| 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | Initial model, covering the path-recovery, WSL clone, vault scoping, package-manager, and SBOM changes of 1.0.1 |
| 2026-10-09 | v1.0.1 | James Maes (@KofTwentyTwo) | T11 updated: releases build in the shared standards workflow; publishing tokens moved to the `release` environment (#76) |
| 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) |
+138
View File
@@ -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<CliErrorException>(() => 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<CliUsageException>(() => 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<CliErrorException>(() => 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<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
+4
View File
@@ -91,12 +91,16 @@ internal sealed class FakeRepoLister : IRepositoryLister
public Exception? Throw { get; set; }
/// <summary>The token of the most recent call, so a test can check which source was used.</summary>
public string? LastToken { get; private set; }
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
LastToken = token;
return Throw is not null
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
: Task.FromResult(Result);
+34
View File
@@ -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<CliErrorException>(
() => 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()
{
+31
View File
@@ -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<CliUsageException>(() => 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<CliUsageException>(() => 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<CliUsageException>(() => Parse("--use-default-token=yes"));
[Fact]
public void TryConsume_NonTokenOption_ReturnsFalse()
{
+2 -1
View File
@@ -21,4 +21,5 @@ internal sealed record AccountSummary(
bool CreateOrgSubfolder,
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary);
string? LastSyncSummary,
string TokenSource);
+62 -14
View File
@@ -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 <name> Display name; must be unique (case-insensitive).
@@ -49,6 +51,10 @@ internal static class AccountsCommand
--token-env <VAR> | --token-file <path> | --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 <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 <name> Deletes the account's settings and its stored
@@ -137,7 +146,7 @@ internal static class AccountsCommand
IReadOnlyList<AccountSummary> 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
/// <summary>'own' or 'default': the token source as the list column and the JSON field show it.</summary>
private static string TokenSourceLabel(Account account) => account.UsesDefaultToken ? "default" : "own";
/// <summary>
/// --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).
/// </summary>
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.");
}
}
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
private static void SaveOrFail(AccountsStore store, Account account, string? token)
{
+9 -6
View File
@@ -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 <name> --token-stdin',"
: "Re-enter the token with 'gclo accounts edit --name <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 <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;
}
+35 -7
View File
@@ -25,14 +25,25 @@ internal sealed class TokenOptions
private bool _useStdin;
private bool _useDefault;
/// <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.
/// 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.
/// </summary>
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;
/// <summary>
/// --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). <see cref="Resolve"/> rejects it, as it names no token value.
/// </summary>
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
/// <summary>Usage error when more than one token option was given; a no-op otherwise.</summary>
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.");
}
}
/// <summary>
/// Resolves the token from the single configured source, or from the
/// <see cref="DefaultVariable"/> environment variable when none was given.
@@ -65,10 +92,11 @@ internal sealed class TokenOptions
/// </summary>
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)
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="AccountTokenResolver"/>: an account's own entry for <see cref="TokenSource.Own"/>,
/// the default token for <see cref="TokenSource.Default"/>, null (with a source-specific
/// description) when the source holds nothing.
/// </summary>
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<ArgumentNullException>(() => new AccountTokenResolver(null!));
[Fact]
public void NullAccount_Throws()
{
var resolver = new AccountTokenResolver(_vault);
Assert.Throws<ArgumentNullException>(() => resolver.TryResolve(null!));
Assert.Throws<ArgumentNullException>(() => 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);
}
}
@@ -390,6 +390,206 @@ public sealed class AccountWizardViewModelTests : IDisposable
// ---------------------------------------------------------------- default token (#102)
private AccountWizardViewModel NewWizardWithDefault(string? defaultToken, Account? existing = null, string? existingToken = null)
=> new(_store, _orgs, _defaults, existing, () => existingToken, defaultToken: () => defaultToken);
[Fact]
public void NewWizard_WithoutADefaultToken_OffersNoChoice_AndUsesAnOwnToken()
{
AccountWizardViewModel wizard = NewWizardWithDefault(defaultToken: null);
Assert.False(wizard.HasDefaultToken);
Assert.False(wizard.UseDefaultToken);
Assert.True(wizard.IsOwnTokenSelected);
Assert.Equal(1, wizard.TokenChoiceIndex);
}
[Fact]
public void NewWizard_WithADefaultToken_PreselectsIt()
{
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
Assert.True(wizard.HasDefaultToken);
Assert.True(wizard.UseDefaultToken);
Assert.False(wizard.IsOwnTokenSelected);
Assert.Equal(0, wizard.TokenChoiceIndex);
Assert.Equal("", wizard.Token); // the default token is never loaded into the box
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal);
}
[Fact]
public void TokenChoiceIndex_RoundTripsToUseDefaultToken_AndNotifies()
{
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
var changed = new List<string?>();
wizard.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
wizard.TokenChoiceIndex = 1;
Assert.False(wizard.UseDefaultToken);
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.IsOwnTokenSelected), StringComparison.Ordinal));
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.TokenChoiceIndex), StringComparison.Ordinal));
Assert.Contains(changed, n => string.Equals(n, nameof(AccountWizardViewModel.DefaultTokenCaption), StringComparison.Ordinal));
wizard.TokenChoiceIndex = 0;
Assert.True(wizard.UseDefaultToken);
}
[Fact]
public async Task NewWizard_UsingTheDefaultToken_ValidatesWithIt_AndSavesWithoutAnOwnEntry()
{
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
wizard.Name = "Shared";
string? validated = null;
_orgs.Handler = (token, _) =>
{
validated = token;
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
};
await AdvanceToStepAsync(wizard, 3);
Assert.Equal("ghp_default", validated);
Assert.Equal("", wizard.TokenError);
wizard.Organization = "acme";
wizard.TargetRoot = @"C:\repos";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.True(saved.UsesDefaultToken);
Assert.Null(_vault.TryRetrieve(saved.Id));
Assert.Equal(1, _store.CountUsingDefaultToken());
}
[Fact]
public async Task NewWizard_UsingTheDefaultToken_WhenItWasRemovedMeanwhile_StaysOnStepTwo()
{
string? liveDefault = "ghp_default";
var wizard = new AccountWizardViewModel(_store, _orgs, _defaults, defaultToken: () => liveDefault);
wizard.Name = "Shared";
await AdvanceToStepAsync(wizard, 2);
liveDefault = null; // removed in Settings while the wizard is open
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Contains("default token is no longer saved", wizard.TokenError);
Assert.Contains("Settings", wizard.TokenError);
}
[Fact]
public async Task NewWizard_ChoosingAnOwnToken_IgnoresTheDefault_AndStoresTheTypedOne()
{
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default");
wizard.Name = "Mine";
wizard.UseDefaultToken = false;
wizard.Token = "ghp_mine";
string? validated = null;
_orgs.Handler = (token, _) =>
{
validated = token;
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
};
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "acme";
wizard.TargetRoot = @"C:\repos";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal("ghp_mine", validated);
Account saved = Assert.Single(_store.GetAll());
Assert.False(saved.UsesDefaultToken);
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
}
[Fact]
public async Task EditWizard_DefaultTokenAccount_StartsOnTheDefault_AndCanSwitchToAnOwnToken()
{
Account existing = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
_store.Save(existing, null);
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing);
Assert.True(wizard.UseDefaultToken);
Assert.StartsWith("Used by Quick Sync.", wizard.DefaultTokenCaption, StringComparison.Ordinal); // itself excluded
wizard.UseDefaultToken = false;
await AdvanceToStepAsync(wizard, 2);
Assert.False(await wizard.TryAdvanceAsync()); // no own token typed yet
Assert.Contains("instead of the default one", wizard.TokenError);
wizard.Token = "ghp_mine";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.False(saved.UsesDefaultToken);
Assert.Equal("ghp_mine", _vault.TryRetrieve(saved.Id));
}
[Fact]
public async Task EditWizard_OwnTokenAccount_SwitchingToTheDefault_RemovesItsOwnEntry_AndSaysSo()
{
Account existing = MakeAccount("Work");
_store.Save(existing, "ghp_own");
_store.Save(MakeAccount("Other") with { TokenSource = TokenSource.Default }, null);
AccountWizardViewModel wizard = NewWizardWithDefault("ghp_default", existing, "ghp_own");
Assert.False(wizard.UseDefaultToken);
wizard.UseDefaultToken = true;
Assert.Contains("1 other account", wizard.DefaultTokenCaption);
Assert.Contains("own token will be removed", wizard.DefaultTokenCaption);
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Account saved = _store.FindByName("Work")!;
Assert.True(saved.UsesDefaultToken);
Assert.Null(_vault.TryRetrieve(saved.Id));
}
[Fact]
public void DefaultTokenCaption_CountsOtherAccounts()
{
_store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
_store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
Assert.Contains("2 other accounts", NewWizardWithDefault("ghp_default").DefaultTokenCaption);
}
[Fact]
public void SeededWizard_PreselectsTheDefault_OnlyWhenTheSeedTokenIsTheDefaultToken()
{
var onDefault = new AccountWizardSeed("ghp_default", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
var other = new AccountWizardSeed("ghp_other", "acme", @"C:\src", CreateOrgSubfolder: false, MaxConcurrency: 8);
Assert.True(new AccountWizardViewModel(_store, _orgs, onDefault, defaultToken: () => "ghp_default").UseDefaultToken);
Assert.False(new AccountWizardViewModel(_store, _orgs, other, defaultToken: () => "ghp_default").UseDefaultToken);
Assert.False(new AccountWizardViewModel(_store, _orgs, onDefault).UseDefaultToken);
}
[Fact]
public void SeededWizard_NullSeed_Throws()
{
@@ -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.
/// </summary>
// ---------------------------------------------------------------- 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<UnauthorizedAccessException>(() => 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<UnauthorizedAccessException>(() => 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<AccountConsistencyException>(
() => 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();
+105
View File
@@ -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<ArgumentException>(() => 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]
+15 -2
View File
@@ -128,18 +128,31 @@ public sealed class ActivityLogTests : IDisposable
[Fact]
public void DefaultDirectory_IsLocalAppDataGcloLogs()
public void DefaultDirectory_IsLocalAppDataKofTwentyTwoGcloLogs()
{
var log = new FileActivityLog();
string expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs");
"KofTwentyTwo", "gclo", "logs");
Assert.Equal(expected, log.LogDirectory);
}
[Fact]
public void DefaultDirectory_IsNotInsideTheVelopackInstallRoot()
{
// Setup.exe deletes %LOCALAPPDATA%\gclo (the install root) on reinstall (#91).
string installRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
Assert.False(
new FileActivityLog().LogDirectory.StartsWith(installRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Directory_IsCreatedLazily_OnFirstWriteNotInConstructor()
{
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Where gclo keeps its per-user data. The default must stay outside the Velopack
/// install root (%LOCALAPPDATA%\gclo): Setup.exe uninstalls an existing install
/// first and deletes that folder, which destroyed accounts.json and settings.json
/// in 1.0.2-beta.1 (#91).
/// </summary>
public sealed class GcloPathsTests
{
/// <summary>The default root is %LOCALAPPDATA%\KofTwentyTwo\gclo.</summary>
[Fact]
public void DefaultDataRoot_IsLocalAppDataKofTwentyTwoGclo()
{
string expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KofTwentyTwo", "gclo");
Assert.Equal(expected, GcloPaths.DefaultDataRoot);
}
/// <summary>The default root is never the install root nor anything under it.</summary>
[Fact]
public void DefaultDataRoot_IsNotTheVelopackInstallRoot()
{
string installRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
Assert.NotEqual(installRoot, GcloPaths.DefaultDataRoot, StringComparer.OrdinalIgnoreCase);
Assert.False(
GcloPaths.DefaultDataRoot.StartsWith(installRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
}
}
@@ -132,6 +132,69 @@ public sealed class SyncAllCoordinatorTests : IDisposable
// ---------------------------------------------------------------- default token (#102)
/// <summary>A default-token account workspace; the default token is seeded only when asked.</summary>
private WorkspaceViewModel CreateDefaultTokenWorkspace(string name, bool withDefaultToken)
{
var account = new Account
{
Id = Guid.NewGuid(),
Name = name,
Organization = "org-" + name,
TargetRoot = Path.Combine(_root, name),
TokenSource = TokenSource.Default,
};
if(withDefaultToken)
{
_vault.Store(AppSettings.DefaultTokenVaultId, "tok-default");
}
var workspace = new WorkspaceViewModel(
_lister,
new FakeGitClient(),
_orgs,
handler => new SyncProgress(handler),
TimeSpan.FromMilliseconds(1),
new NullActivityLog(),
account,
_vault);
_workspaces.Add(workspace);
return workspace;
}
[Fact]
public async Task RunAsync_DefaultTokenAccount_WithoutADefaultToken_IsSkipped_NotRun()
{
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: false);
Assert.True(shared.UsesDefaultToken);
Assert.Equal("", shared.Token);
var states = new List<SyncAllAccountState>();
_coordinator.AccountStateChanged = (_, state) => states.Add(state);
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
Assert.Equal(0, result.Ran);
Assert.Equal(1, result.Skipped);
Assert.Equal([SyncAllAccountState.Queued, SyncAllAccountState.Skipped], states);
Assert.Empty(_lister.Organizations); // never asked GitHub: skipped before loading
}
[Fact]
public async Task RunAsync_DefaultTokenAccount_WithADefaultToken_Runs()
{
WorkspaceViewModel shared = CreateDefaultTokenWorkspace("shared", withDefaultToken: true);
Assert.Equal("tok-default", shared.Token);
SyncAllResult result = await _coordinator.RunAsync([shared], CancellationToken.None);
Assert.Equal(1, result.Ran);
Assert.Equal(0, result.Skipped);
}
// ---------------------------------------------------------------- ordering
[Fact]
@@ -1793,6 +1793,55 @@ 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<IReadOnlyList<string>>(["acme"]);
};
WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault);
Assert.Equal("default-token-1234567890", vm.Token);
Assert.True(vm.UsesDefaultToken);
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()
{
+4 -2
View File
@@ -25,14 +25,16 @@ public sealed class FileActivityLog : IActivityLog
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">
/// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank.
/// Folder for the log files; defaults to %LOCALAPPDATA%\KofTwentyTwo\gclo\logs
/// when null or blank (the same data root as the app; never the Velopack
/// install root %LOCALAPPDATA%\gclo, which Setup.exe deletes, #91).
/// </param>
public FileActivityLog(string? directory = null)
{
LogDirectory = string.IsNullOrWhiteSpace(directory)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs")
"KofTwentyTwo", "gclo", "logs")
: directory;
}
+19 -2
View File
@@ -3,20 +3,37 @@
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
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.
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
/// default token when <see cref="TokenSource"/> says so) 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>
/// 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
/// <see cref="AccountTokenResolver"/>.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TokenSource>))]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public TokenSource TokenSource { get; init; } = TokenSource.Own;
/// <summary>True when the account resolves to the default token from Settings.</summary>
[JsonIgnore]
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
public required string Name { get; init; }
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// The one place that turns an <see cref="Account"/> into the token to use for it:
/// its own vault entry for <see cref="TokenSource.Own"/>, the default token from
/// Settings for <see cref="TokenSource.Default"/>. 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).
/// </summary>
public sealed class AccountTokenResolver
{
private readonly ITokenVault _vault;
/// <summary>Resolves against <paramref name="vault"/>.</summary>
public AccountTokenResolver(ITokenVault vault)
{
ArgumentNullException.ThrowIfNull(vault);
_vault = vault;
}
/// <summary>
/// The token for <paramref name="account"/>, or null when its source holds
/// nothing (no default token saved, or the account's own entry is gone).
/// </summary>
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;
}
/// <summary>True when the vault holds a default token.</summary>
public bool HasDefaultToken => !string.IsNullOrEmpty(_vault.TryRetrieve(AppSettings.DefaultTokenVaultId));
/// <summary>
/// Why <see cref="TryResolve"/> returned null for <paramref name="account"/>, worded
/// for the user and naming the source, so every "no token" message says what to fix.
/// Never contains a token.
/// </summary>
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}')";
}
}
+108 -11
View File
@@ -36,13 +36,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// </summary>
private readonly Func<string?>? _storedToken;
/// <summary>
/// Fetches the default token from Settings on demand (null, or returning null,
/// when none is saved). Read when step 2 validates or the account is saved with
/// "Use the default token"; never copied into <see cref="Token"/> (#102).
/// </summary>
private readonly Func<string?>? _defaultToken;
/// <summary>
/// A wizard for a new account seeded from <paramref name="defaults"/>, or — when
/// <paramref name="existing"/> is given — an edit wizard seeded from that account.
/// <paramref name="storedToken"/> fetches the vault's current token on demand for
/// an edit (null, or returning null, when the vault has no entry).
/// an edit (null, or returning null, when the vault has no entry);
/// <paramref name="defaultToken"/> fetches the default token from Settings the
/// same way and, when it returns one, offers "Use the default token" on step 2.
/// </summary>
public AccountWizardViewModel(
AccountsStore store,
@@ -50,8 +59,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
AppSettings defaults,
Account? existing = null,
Func<string?>? storedToken = null,
IActivityLog? log = null)
: this(store, orgLister, existing, storedToken, log)
IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, existing, storedToken, defaultToken, log)
{
ArgumentNullException.ThrowIfNull(defaults);
@@ -85,13 +95,19 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// always written to the vault on save.
/// </summary>
public AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null)
: this(store, orgLister, existing: null, storedToken: null, log)
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null,
Func<string?>? defaultToken = null)
: this(store, orgLister, existing: null, storedToken: null, defaultToken, log)
{
ArgumentNullException.ThrowIfNull(seed);
Name = "";
Description = "";
Token = seed.Token;
// A Quick Sync connection that runs on the default token becomes a
// default-token account; any other token is the account's own. Compared in
// memory only; neither value is logged.
UseDefaultToken = HasDefaultToken
&& string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal);
Organization = seed.Organization;
TargetRoot = seed.TargetRoot;
CreateOrgSubfolder = seed.CreateOrgSubfolder;
@@ -101,7 +117,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
private AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken, IActivityLog? log)
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken,
Func<string?>? defaultToken, IActivityLog? log)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
@@ -110,6 +127,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
_log = log ?? new NullActivityLog();
_existing = existing;
_storedToken = existing is null ? null : storedToken;
_defaultToken = defaultToken;
// Decided once: the choice is offered only when a default token exists at
// open time, and the value itself is fetched again when a step needs it.
HasDefaultToken = !string.IsNullOrWhiteSpace(defaultToken?.Invoke());
UseDefaultToken = existing is null ? HasDefaultToken : existing.UsesDefaultToken;
Step = 1;
NameError = "";
@@ -167,6 +189,59 @@ public sealed partial class AccountWizardViewModel : ObservableObject
[ObservableProperty]
public partial bool IsValidatingToken { get; set; }
/// <summary>
/// True when a default token was saved in Settings when the wizard opened, which
/// is when step 2 offers the choice between it and a token of the account's own.
/// </summary>
public bool HasDefaultToken { get; }
/// <summary>
/// Step 2's choice: true means the account resolves to the default token from
/// Settings (<see cref="TokenSource.Default"/>) and the token box is disabled;
/// false means a token of its own. Preselected for a new account when a default
/// token exists, and from the account's current source for an edit.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOwnTokenSelected))]
[NotifyPropertyChangedFor(nameof(TokenChoiceIndex))]
[NotifyPropertyChangedFor(nameof(DefaultTokenCaption))]
public partial bool UseDefaultToken { get; set; }
/// <summary>The token box is enabled only for a token of the account's own.</summary>
public bool IsOwnTokenSelected => !UseDefaultToken;
/// <summary>
/// <see cref="UseDefaultToken"/> as the selected index of a two-item radio group:
/// 0 = the default token, 1 = a token of this account's own.
/// </summary>
public int TokenChoiceIndex
{
get => UseDefaultToken ? 0 : 1;
set => UseDefaultToken = value == 0;
}
/// <summary>
/// What choosing the default token means for this account, with how many other
/// accounts share it; shown under the radio button.
/// </summary>
public string DefaultTokenCaption
{
get
{
int others = _store.CountUsingDefaultToken() - (_existing?.UsesDefaultToken == true ? 1 : 0);
string shared = others switch
{
0 => "Used by Quick Sync.",
1 => "Used by Quick Sync and 1 other account.",
_ => $"Used by Quick Sync and {others} other accounts.",
};
string switching = IsEditing && _existing?.UsesDefaultToken == false && UseDefaultToken
? " This account's own token will be removed from Windows Credential Manager on save."
: "";
return shared + " Replacing it in Settings updates this account too." + switching;
}
}
/// <summary>Step 1's validation message; empty when the name is acceptable.</summary>
[ObservableProperty]
public partial string NameError { get; set; }
@@ -254,16 +329,34 @@ public sealed partial class AccountWizardViewModel : ObservableObject
// token and returns the organizations the dropdown offers otherwise.
// An edit with the box left empty validates the STORED token, fetched
// here and used for this one call only.
string candidate = Token.Trim();
if(candidate.Length == 0 && IsEditing)
string candidate;
if(UseDefaultToken)
{
candidate = _storedToken?.Invoke()?.Trim() ?? "";
// The default token is fetched for this one call only; the box
// stays untouched (and disabled) so it is never shown or retained.
candidate = _defaultToken?.Invoke()?.Trim() ?? "";
if(candidate.Length == 0)
{
TokenError = "This account has no stored token. Enter one to continue.";
TokenError = "The default token is no longer saved. Save one in Settings, "
+ "or use a different token for this account.";
return false;
}
}
else
{
candidate = Token.Trim();
if(candidate.Length == 0 && IsEditing)
{
candidate = _storedToken?.Invoke()?.Trim() ?? "";
if(candidate.Length == 0)
{
TokenError = _existing?.UsesDefaultToken == true
? "Enter the token this account should use instead of the default one."
: "This account has no stored token. Enter one to continue.";
return false;
}
}
}
IsValidatingToken = true;
_log.Info("Account wizard: validating the token.");
@@ -333,7 +426,10 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// </summary>
public Task SaveAsync()
{
_store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null);
// A default-token account stores no token of its own (the store removes any
// leftover entry when an account switches); otherwise new accounts always
// persist their token and edits only when one was typed.
_store.Save(BuildAccount(), !UseDefaultToken && TokenChanged ? Token.Trim() : null);
return Task.CompletedTask;
}
@@ -353,6 +449,7 @@ public sealed partial class AccountWizardViewModel : ObservableObject
TargetRoot = TargetRoot.Trim(),
CreateOrgSubfolder = CreateOrgSubfolder,
MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency),
TokenSource = UseDefaultToken ? TokenSource.Default : TokenSource.Own,
LastSyncUtc = _existing?.LastSyncUtc,
LastSyncSummary = _existing?.LastSyncSummary,
};
+68 -1
View File
@@ -12,7 +12,7 @@ namespace gclo.ViewModels;
/// <summary>
/// Persists account profiles as JSON at accounts.json under
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\gclo) and their
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\KofTwentyTwo\gclo) 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
@@ -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
/// <summary>
/// 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.
/// </summary>
private void SaveUsingDefaultToken(List<Account> 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)."));
}
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
public int CountUsingDefaultToken()
{
lock(_gate)
{
return _accounts.Count(a => a.UsesDefaultToken);
}
}
/// <summary>
/// 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
+1 -1
View File
@@ -11,7 +11,7 @@ namespace gclo.ViewModels;
/// <summary>
/// User preferences persisted as JSON at settings.json under
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\gclo, overridable via
/// <see cref="GcloPaths.DataRoot"/> (default %LOCALAPPDATA%\KofTwentyTwo\gclo, overridable via
/// GCLO_DATA_DIR). Uses Environment.GetFolderPath (not Windows.Storage.ApplicationData)
/// so the same path works whether the app runs packaged or unpackaged.
/// </summary>
+19 -8
View File
@@ -8,17 +8,23 @@ namespace gclo.ViewModels;
/// <summary>
/// The single seam for where gclo keeps its per-user data (settings, accounts,
/// logs). Defaults to %LOCALAPPDATA%\gclo; the GCLO_DATA_DIR environment variable
/// overrides it so UI tests (and portable setups) can point the whole app at an
/// isolated directory without touching the real profile. Components that accept
/// an explicit directory (test seams) are unaffected — this only feeds defaults.
/// logs). Defaults to %LOCALAPPDATA%\KofTwentyTwo\gclo; the GCLO_DATA_DIR
/// environment variable overrides it so UI tests (and portable setups) can point
/// the whole app at an isolated directory without touching the real profile.
/// Components that accept an explicit directory (test seams) are unaffected —
/// this only feeds defaults.
/// </summary>
/// <remarks>
/// The data root must never be the Velopack install root (%LOCALAPPDATA%\gclo):
/// Setup.exe uninstalls an existing install first and deletes that whole folder,
/// which destroyed the accounts and settings stored there (#91).
/// </remarks>
public static class GcloPaths
{
/// <summary>
/// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and
/// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a
/// value set before launch always wins over any cached default.
/// non-empty, otherwise <see cref="DefaultDataRoot"/>. Read on every access so
/// a value set before launch always wins over any cached default.
/// </summary>
public static string DataRoot =>
Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir
@@ -27,7 +33,12 @@ public static class GcloPaths
/// <summary>%LOCALAPPDATA%\gclo: the root used when GCLO_DATA_DIR is not set.</summary>
/// <summary>
/// %LOCALAPPDATA%\KofTwentyTwo\gclo: the root used when GCLO_DATA_DIR is not
/// set. Outside the Velopack install root on purpose (see the type remarks).
/// </summary>
public static string DefaultDataRoot =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo");
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"KofTwentyTwo", "gclo");
}
+10
View File
@@ -78,6 +78,16 @@ public sealed class SyncAllCoordinator(IActivityLog log)
continue;
}
if(workspace.UsesDefaultToken && string.IsNullOrEmpty(workspace.Token))
{
// The account resolves to the default token and none is saved: a
// generic auth failure would send the user to the wrong place.
skipped++;
Announce(workspace, SyncAllAccountState.Skipped);
_log.Info($"Sync all: skipped '{workspace.DisplayName}' — no default token is saved (Settings).");
continue;
}
Announce(workspace, SyncAllAccountState.Running);
if(!workspace.HasLoadedRepos)
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.ViewModels;
/// <summary>
/// Where an account's GitHub token comes from. <see cref="Own"/> is the account's
/// own vault entry (keyed by its id); <see cref="Default"/> is the default token
/// saved in Settings (<see cref="AppSettings.DefaultTokenVaultId"/>), read at use
/// time, so replacing the default token in Settings rotates every such account
/// at once (docs/plans/default-token-ux.md).
/// </summary>
public enum TokenSource
{
/// <summary>The account's own token, stored under its id. The on-disk default.</summary>
Own = 0,
/// <summary>The default token from Settings, resolved whenever the account is used.</summary>
Default = 1,
}
+10 -2
View File
@@ -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;
}
@@ -156,6 +157,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Name shown for this workspace in navigation: the account's name, or "Quick Sync".</summary>
public string DisplayName => _account?.Name ?? "Quick Sync";
/// <summary>
/// True for an account workspace whose token is the default token from Settings
/// (<see cref="TokenSource.Default"/>): the pane tags it, and Sync All names
/// Settings when that token is missing.
/// </summary>
public bool UsesDefaultToken => _account?.UsesDefaultToken == true;
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
+19
View File
@@ -57,12 +57,31 @@
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Step 2 of 4: Access token" />
<!-- Offered only when a default token is saved in Settings (#102). The
account then references that token instead of holding a copy, so
replacing it in Settings updates every such account. -->
<RadioButtons
x:Name="TokenChoice"
AutomationProperties.AutomationId="WizardTokenChoice"
Header="Which token should this account use?"
SelectedIndex="{x:Bind ViewModel.TokenChoiceIndex, Mode=TwoWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasDefaultToken), Mode=OneWay}">
<RadioButton AutomationProperties.AutomationId="WizardUseDefaultToken" Content="Use the default token from Settings" />
<RadioButton AutomationProperties.AutomationId="WizardUseOwnToken" Content="Use a different token for this account" />
</RadioButtons>
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.DefaultTokenCaption, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.UseDefaultToken), Mode=OneWay}" />
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="WizardTokenBox"
Header="Personal access token"
ToolTipService.ToolTip="Stored in Windows Credential Manager, never in a file; needs repository read access"
PlaceholderText="ghp_…"
IsEnabled="{x:Bind ViewModel.IsOwnTokenSelected, Mode=OneWay}"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
+1 -1
View File
@@ -57,7 +57,7 @@ public sealed partial class AccountWizardDialog : ContentDialog
// by hand. The stored token of an existing account is deliberately NOT
// loaded into the box: an empty box means "keep it", and the secret never
// sits in an editable control with a reveal button (#32).
TokenBox.PlaceholderText = ViewModel.IsEditing
TokenBox.PlaceholderText = ViewModel.IsEditing && !ViewModel.UseDefaultToken
? "Leave empty to keep the stored token"
: "ghp_…";
+34 -4
View File
@@ -615,11 +615,12 @@ public sealed partial class MainWindow : Window
{
var item = new NavigationViewItem
{
Content = account.Name,
Content = AccountItemContent(account),
Tag = account.Id,
Icon = new FontIcon { Glyph = "" },
ContextFlyout = CreateAccountContextFlyout(account.Id),
};
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
_navItems[account.Id] = item;
return item;
@@ -627,6 +628,31 @@ public sealed partial class MainWindow : Window
/// <summary>
/// The pane item's content: the name, plus a "Default token" tag for an account
/// that resolves to the default token from Settings, so the dependency on
/// Settings is visible where the account is used (#102).
/// </summary>
private static object AccountItemContent(Account account)
{
if(!account.UsesDefaultToken)
{
return account.Name;
}
var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
panel.Children.Add(new TextBlock { Text = account.Name, VerticalAlignment = VerticalAlignment.Center });
panel.Children.Add(new TextBlock
{
Text = "Default token",
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"],
});
return panel;
}
/// <summary>The Edit/Delete context menu for an account's pane item.</summary>
private MenuFlyout CreateAccountContextFlyout(Guid id)
{
@@ -688,7 +714,8 @@ public sealed partial class MainWindow : Window
// validation call; the wizard never displays or retains it.
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
var viewModel = new AccountWizardViewModel(
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log);
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
@@ -737,7 +764,9 @@ public sealed partial class MainWindow : Window
/// </summary>
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
{
var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log);
var viewModel = new AccountWizardViewModel(
_accountsStore, new GitHubOrganizationLister(), seed, _log,
defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId));
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
@@ -798,7 +827,8 @@ public sealed partial class MainWindow : Window
if(_navItems.TryGetValue(id, out NavigationViewItem? item))
{
item.Content = account.Name;
item.Content = AccountItemContent(account);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(item, account.UsesDefaultToken ? $"{account.Name}, uses the default token" : account.Name);
ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced");
}
+7
View File
@@ -12,6 +12,13 @@
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<!-- gclo ships only unpackaged and self-contained (Velopack). Fixing both here keeps
every build and publish on one configuration: the resource index (gclo.pri)
is generated incrementally in obj, and a framework-dependent build followed by
a self-contained publish left a stale index without the WinUI theme resources,
which crashed 1.0.1 at startup (0xc000027b). -->
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
+2 -2
View File
@@ -6,7 +6,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>gclo</id>
<version>1.0.0</version>
<version>1.0.1</version>
<title>gclo (Git Clone Large Organizations)</title>
<authors>James Maes</authors>
<owners>KofTwentyTwo</owners>
@@ -35,7 +35,7 @@ A GitHub personal access token is required: set `GITHUB_TOKEN`, or use `--token-
The desktop app (WinUI 3) is a separate download from the GitHub Releases page.
]]></description>
<releaseNotes>https://github.com/KofTwentyTwo/gclo/releases/tag/v1.0.0</releaseNotes>
<releaseNotes>https://github.com/KofTwentyTwo/gclo/releases/tag/v1.0.1</releaseNotes>
</metadata>
<files>
<file src="tools\**" target="tools" />
@@ -11,8 +11,8 @@ $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$packageArgs = @{}
$packageArgs.packageName = 'gclo'
$packageArgs.unzipLocation = $toolsDir
$packageArgs.url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip'
$packageArgs.checksum64 = '42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee'
$packageArgs.url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.1/gclo-cli-win-x64-1.0.1.zip'
$packageArgs.checksum64 = '641ae4100022fba3fe489f8abe6ccc43186c070087d3edbf3a7891d5dc7428f7'
$packageArgs.checksumType64 = 'sha256'
Install-ChocolateyZipPackage @packageArgs