Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9667f437b7
|
||
|
|
c0a99cacd8
|
||
|
|
ef86a52bc6
|
||
|
|
7234c18549 | ||
|
|
2c4726e30e | ||
|
|
c4d84779ab | ||
|
|
e411c30344 | ||
|
|
e63b64fb89 | ||
|
|
7511d4568d | ||
|
|
34b6870f3d | ||
|
|
f2b3900490 |
No files matched your search
@@ -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'"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -21,4 +21,5 @@ internal sealed record AccountSummary(
|
||||
bool CreateOrgSubfolder,
|
||||
int MaxConcurrency,
|
||||
DateTimeOffset? LastSync,
|
||||
string? LastSyncSummary);
|
||||
string? LastSyncSummary,
|
||||
string TokenSource);
|
||||
+62
-14
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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}')";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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). -->
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user