初始化: 完成Git Skill测试基线

Co-authored-by: 旅行呀~ <travelxiao@qq.com>
This commit is contained in:
2026-08-20 23:42:02 +08:00
commit 2cddf06b6c
30 changed files with 3422 additions and 0 deletions
@@ -0,0 +1,50 @@
# Agent-safe: refuse to proceed if secret-looking paths are staged or untracked.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$patterns = @(
'\.dpapi$',
'(^|/)token\.txt$',
'(^|/)\.env$',
'(^|/)\.env\.',
'\.pem$',
'(^|/)id_rsa$',
'(^|/)id_ed25519$',
'(^|/)id_ecdsa$',
'(^|/)\.git-skills/'
)
$blocked = New-Object System.Collections.Generic.List[string]
$lines = Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain', '-uall')
foreach ($line in $lines) {
if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue }
$path = $line.Substring(3).Trim().Trim('"')
$path = $path -replace ' -> .+$', ''
$norm = $path.Replace('\', '/')
foreach ($re in $patterns) {
if ($norm -match $re) {
$blocked.Add($norm) | Out-Null
break
}
}
}
if ($blocked.Count -gt 0) {
Write-StatusLine -Key 'status' -Value 'blocked'
Write-StatusLine -Key 'reason' -Value 'secret_paths'
foreach ($p in $blocked) {
Write-StatusLine -Key 'path' -Value $p
}
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
exit 0
+291
View File
@@ -0,0 +1,291 @@
# Shared helpers for git-init skill scripts.
# Dot-source only. Never prints token values.
$ErrorActionPreference = 'Stop'
if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) {
$PSNativeCommandUseErrorActionPreference = $false
}
# Windows PowerShell 5.1 promotes native stderr to a terminating error when
# $ErrorActionPreference='Stop', so 2>$null alone is not enough. This helper
# runs a native executable with stderr discarded and $LASTEXITCODE preserved.
function Invoke-GitSkillsNative {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @()
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = @(& $FilePath @ArgumentList 2>$null | ForEach-Object { "$_" })
return ,$output
}
finally {
$ErrorActionPreference = $prevEap
}
}
function Invoke-GitSkillsNativeWithInput {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$InputText
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.Arguments = ($ArgumentList | ForEach-Object {
if ($_ -match '\s|"') { '"{0}"' -f ($_ -replace '"', '\"') } else { $_ }
}) -join ' '
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
foreach ($line in ($InputText -split "`r?`n", 0)) {
$proc.StandardInput.WriteLine($line)
}
$proc.StandardInput.Close()
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
$script:LastNativeExitCode = $proc.ExitCode
if ($stderr) { $null = $stderr }
if ([string]::IsNullOrWhiteSpace($stdout)) { return @() }
return @($stdout -split "`r?`n")
}
Add-Type -AssemblyName System.Security | Out-Null
$script:GitSkillsHome = Join-Path $env:USERPROFILE '.git-skills'
$script:ProfilePath = Join-Path $script:GitSkillsHome 'profile.json'
$script:TokenPath = Join-Path $script:GitSkillsHome 'token.dpapi'
$script:DefaultsPath = Join-Path $PSScriptRoot 'defaults.json'
function Get-GitSkillsDefaults {
if (-not (Test-Path -LiteralPath $script:DefaultsPath)) {
return [pscustomobject]@{
userName = 'Traveler'
userEmail = 'user@example.com'
}
}
return Get-Content -LiteralPath $script:DefaultsPath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Ensure-GitSkillsHome {
if (-not (Test-Path -LiteralPath $script:GitSkillsHome)) {
New-Item -ItemType Directory -Path $script:GitSkillsHome -Force | Out-Null
}
}
function Get-GitSkillsProfile {
if (-not (Test-Path -LiteralPath $script:ProfilePath)) {
return $null
}
return Get-Content -LiteralPath $script:ProfilePath -Raw -Encoding UTF8 | ConvertFrom-Json
}
function Save-GitSkillsProfile {
param(
[Parameter(Mandatory = $true)]
$Profile
)
Ensure-GitSkillsHome
$json = $Profile | ConvertTo-Json -Depth 5
$utf8Bom = New-Object System.Text.UTF8Encoding $true
[System.IO.File]::WriteAllText($script:ProfilePath, $json, $utf8Bom)
}
function Protect-SecretString {
param(
[Parameter(Mandatory = $true)]
[string]$PlainText
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText)
return [System.Security.Cryptography.ProtectedData]::Protect(
$bytes,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
}
function Unprotect-SecretBytes {
param(
[Parameter(Mandatory = $true)]
[byte[]]$Data
)
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
$Data,
$null,
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
)
return [System.Text.Encoding]::UTF8.GetString($bytes)
}
function ConvertFrom-SecureStringPlain {
param(
[Parameter(Mandatory = $true)]
[System.Security.SecureString]$Secure
)
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try {
return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
function Parse-GitRemoteUrl {
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$url = $RemoteUrl.Trim()
if ([string]::IsNullOrWhiteSpace($url)) {
throw 'Remote URL is empty.'
}
function New-ParsedRemote {
param(
[string]$Protocol,
[string]$HostName,
[object]$Port,
[string]$Path
)
$portNum = $null
if ($null -ne $Port -and "$Port" -ne '') {
$portNum = [int]$Port
}
$credentialHost = $HostName
if ($Protocol -eq 'https' -or $Protocol -eq 'http') {
$defaultPort = if ($Protocol -eq 'https') { 443 } else { 80 }
if ($null -ne $portNum -and $portNum -ne $defaultPort) {
$credentialHost = '{0}:{1}' -f $HostName, $portNum
}
}
return [pscustomobject]@{
Protocol = $Protocol
Host = $HostName
Port = $portNum
Path = $Path
CredentialHost = $credentialHost
}
}
# Gitea / Git SCP: git@host:owner/repo.git
if ($url -match '^git@([^:]+):(.+)$') {
return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1] -Port $null -Path $Matches[2]
}
# ssh://git@host:2222/owner/repo.git or ssh://git@host/owner/repo.git
if ($url -match '^ssh://(?:[^@/]+@)?(\[[^\]]+\]|[^:/]+)(?::(\d+))?/(.+)$') {
$port = if ($Matches[2]) { [int]$Matches[2] } else { $null }
return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1].Trim('[]') -Port $port -Path $Matches[3]
}
try {
$uri = [Uri]$url
}
catch {
throw "Unsupported remote URL: $url"
}
if (-not $uri.IsAbsoluteUri -or [string]::IsNullOrWhiteSpace($uri.Host)) {
throw "Unsupported remote URL: $url"
}
$port = $null
if (-not $uri.IsDefaultPort) {
$port = $uri.Port
}
return New-ParsedRemote -Protocol $uri.Scheme -HostName $uri.Host -Port $port -Path $uri.AbsolutePath.TrimStart('/')
}
function Write-StatusLine {
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
Write-Output ("{0}={1}" -f $Key, $Value)
}
function Get-CoAuthorTrailerLines {
param(
$GitProfile = $null
)
if ($null -eq $GitProfile) {
$GitProfile = Get-GitSkillsProfile
}
if ($null -eq $GitProfile) {
return @()
}
$lines = New-Object System.Collections.Generic.List[string]
$primaryName = ([string]$GitProfile.userName).Trim()
$primaryEmail = ([string]$GitProfile.userEmail).Trim()
if (-not [string]::IsNullOrWhiteSpace($primaryName) -and -not [string]::IsNullOrWhiteSpace($primaryEmail)) {
[void]$lines.Add(('Co-authored-by: {0} <{1}>' -f $primaryName, $primaryEmail))
}
$extras = @()
if ($GitProfile.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $GitProfile.coAuthors) {
$extras = @($GitProfile.coAuthors)
}
foreach ($item in $extras) {
$name = ''
$email = ''
if ($null -eq $item) { continue }
if ($item -is [string]) {
if ($item -match '^\s*(.+?)\s*<([^>]+)>\s*$') {
$name = $Matches[1].Trim()
$email = $Matches[2].Trim()
}
}
else {
$name = ([string]$item.name).Trim()
$email = ([string]$item.email).Trim()
}
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($email)) {
continue
}
$line = 'Co-authored-by: {0} <{1}>' -f $name, $email
if (-not $lines.Contains($line)) {
[void]$lines.Add($line)
}
}
return @($lines)
}
function Format-CommitMessageWithTrailers {
param(
[Parameter(Mandatory = $true)]
[string]$Subject,
$GitProfile = $null
)
$subject = $Subject.Trim()
if ([string]::IsNullOrWhiteSpace($subject)) {
throw 'Commit subject is empty.'
}
$trailers = Get-CoAuthorTrailerLines -GitProfile $GitProfile
if ($trailers.Count -eq 0) {
return $subject
}
return ($subject + "`n`n" + ($trailers -join "`n"))
}
@@ -0,0 +1,4 @@
{
"userName": "旅行呀~",
"userEmail": "travelxiao@qq.com"
}
@@ -0,0 +1,24 @@
# Agent-safe: build a commit message with required Co-authored-by trailers.
# Prints the full message only (no key=value). Never prints tokens.
param(
[Parameter(Mandatory = $true)]
[string]$Subject
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-Error 'profile_missing'
exit 1
}
$msg = Format-CommitMessageWithTrailers -Subject $Subject -GitProfile $gitProfile
# Use LF so Git trailers parse consistently on Windows.
$msg = $msg -replace "`r`n", "`n"
[Console]::Out.Write($msg)
if (-not $msg.EndsWith("`n")) {
[Console]::Out.Write("`n")
}
@@ -0,0 +1,202 @@
# Detect or generate a GPG signing key. Print key id only. Never export secret keys.
param(
[string]$Name,
[string]$Email,
[switch]$ForceGenerate
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-Gpg {
$cmd = Get-Command gpg -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$candidates = @(
(Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'),
(Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe')
)
foreach ($path in $candidates) {
if ($path -and (Test-Path -LiteralPath $path)) {
return $path
}
}
return $null
}
function Get-SecretKeyForEmail {
param(
[Parameter(Mandatory = $true)]
[string]$GpgPath,
[Parameter(Mandatory = $true)]
[string]$EmailAddress
)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$lines = Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--list-secret-keys', '--with-colons')
}
finally {
$ErrorActionPreference = $prevEap
}
$currentId = ''
$currentFpr = ''
$matchedId = ''
$matchedFpr = ''
foreach ($line in $lines) {
$parts = $line.Split(':')
if ($parts.Count -lt 2) { continue }
$type = $parts[0]
if ($type -eq 'sec' -or $type -eq 'ssb') {
if ($type -eq 'sec') {
$currentId = $parts[4]
$currentFpr = ''
}
}
elseif ($type -eq 'fpr' -and $parts.Count -ge 10) {
$fpr = $parts[9]
if ([string]::IsNullOrWhiteSpace($currentFpr)) {
$currentFpr = $fpr
if ([string]::IsNullOrWhiteSpace($currentId) -and $fpr.Length -ge 16) {
$currentId = $fpr.Substring($fpr.Length - 16)
}
}
}
elseif ($type -eq 'uid') {
$uid = if ($parts.Count -ge 10) { $parts[9] } else { '' }
if ($uid -match [regex]::Escape($EmailAddress)) {
$matchedId = $currentId
$matchedFpr = $currentFpr
break
}
}
}
if ([string]::IsNullOrWhiteSpace($matchedId)) {
return $null
}
return [pscustomobject]@{
KeyId = $matchedId
Fingerprint = $matchedFpr
}
}
function New-BatchKey {
param(
[Parameter(Mandatory = $true)]
[string]$GpgPath,
[Parameter(Mandatory = $true)]
[string]$RealName,
[Parameter(Mandatory = $true)]
[string]$EmailAddress,
[Parameter(Mandatory = $true)]
[ValidateSet('ed25519', 'rsa4096')]
[string]$Kind
)
if ($Kind -eq 'ed25519') {
$body = @"
%echo generating
Key-Type: EDDSA
Key-Curve: Ed25519
Key-Usage: sign
Subkey-Type: ECDH
Subkey-Curve: Curve25519
Subkey-Usage: encrypt
Name-Real: $RealName
Name-Email: $EmailAddress
Expire-Date: 0
%no-protection
%commit
%echo done
"@
}
else {
$body = @"
%echo generating
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Subkey-Type: RSA
Subkey-Length: 4096
Subkey-Usage: encrypt
Name-Real: $RealName
Name-Email: $EmailAddress
Expire-Date: 0
%no-protection
%commit
%echo done
"@
}
$temp = [System.IO.Path]::GetTempFileName()
try {
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($temp, $body, $utf8)
Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--generate-key', $temp) | Out-Null
return ($LASTEXITCODE -eq 0)
}
finally {
if (Test-Path -LiteralPath $temp) {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
}
}
$gitProfile = Get-GitSkillsProfile
$defaults = Get-GitSkillsDefaults
if ([string]::IsNullOrWhiteSpace($Name)) {
if ($gitProfile) { $Name = [string]$gitProfile.userName }
}
if ([string]::IsNullOrWhiteSpace($Email)) {
if ($gitProfile) { $Email = [string]$gitProfile.userEmail }
}
if ([string]::IsNullOrWhiteSpace($Name)) { $Name = [string]$defaults.userName }
if ([string]::IsNullOrWhiteSpace($Email)) { $Email = [string]$defaults.userEmail }
$gpg = Resolve-Gpg
if ($null -eq $gpg) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_missing'
Write-Output 'hint=Install Gpg4win, then re-run ensure-gpg.ps1'
exit 1
}
$existing = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email
if ($existing -and -not $ForceGenerate) {
Write-StatusLine -Key 'status' -Value 'exists'
Write-StatusLine -Key 'key_id' -Value $existing.KeyId
Write-StatusLine -Key 'fingerprint' -Value $existing.Fingerprint
Write-StatusLine -Key 'gpg' -Value $gpg
exit 0
}
$created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'ed25519'
if (-not $created) {
$created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'rsa4096'
}
if (-not $created) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_generate_failed'
exit 1
}
$createdKey = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email
if ($null -eq $createdKey) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_key_not_found_after_generate'
exit 1
}
Write-StatusLine -Key 'status' -Value 'created'
Write-StatusLine -Key 'key_id' -Value $createdKey.KeyId
Write-StatusLine -Key 'fingerprint' -Value $createdKey.Fingerprint
Write-StatusLine -Key 'gpg' -Value $gpg
@@ -0,0 +1,114 @@
# Detect or generate ED25519 SSH key. Print public key only. Never print the private key.
param(
[string]$Email,
[string]$KeyPath,
[switch]$ForceGenerate,
[switch]$NoGenerate,
[switch]$Quiet
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ([string]::IsNullOrWhiteSpace($Email)) {
if ($gitProfile) { $Email = [string]$gitProfile.userEmail }
}
if ([string]::IsNullOrWhiteSpace($Email)) {
$Email = [string](Get-GitSkillsDefaults).userEmail
}
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
if ($gitProfile -and $gitProfile.sshKeyPath) {
$KeyPath = [string]$gitProfile.sshKeyPath
} else {
$KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
}
if (Test-Path -LiteralPath $KeyPath -PathType Container) {
$KeyPath = Join-Path $KeyPath 'id_ed25519'
}
$pubPath = $KeyPath + '.pub'
$sshDir = Split-Path -Parent $KeyPath
if (-not (Test-Path -LiteralPath $sshDir)) {
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
}
$status = 'exists'
if ($NoGenerate -and -not (Test-Path -LiteralPath $KeyPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
if ($ForceGenerate -or -not (Test-Path -LiteralPath $KeyPath)) {
if ((Test-Path -LiteralPath $KeyPath) -and $ForceGenerate) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'refusing_to_overwrite_ssh_key'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
$sshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue
if ($null -eq $sshKeygen) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_keygen_missing'
exit 1
}
Invoke-GitSkillsNative -FilePath ssh-keygen -ArgumentList @('-t', 'ed25519', '-C', $Email, '-f', $KeyPath, '-N', '""') | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_keygen_failed'
exit 1
}
$status = 'created'
}
if (-not (Test-Path -LiteralPath $pubPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'public_key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
$agent = 'unavailable'
$added = 'skipped'
$svc = Get-Service -Name ssh-agent -ErrorAction SilentlyContinue
if ($null -ne $svc) {
try {
if ($svc.StartType -eq 'Disabled') {
Set-Service -Name ssh-agent -StartupType Manual
}
if ((Get-Service -Name ssh-agent).Status -ne 'Running') {
Start-Service -Name ssh-agent
}
$agent = 'running'
Invoke-GitSkillsNative -FilePath ssh-add -ArgumentList @($KeyPath) | Out-Null
if ($LASTEXITCODE -eq 0) {
$added = 'yes'
} else {
$added = 'failed'
}
}
catch {
$agent = 'error'
$added = 'failed'
}
}
$publicKey = (Get-Content -LiteralPath $pubPath -Raw -Encoding UTF8).Trim()
Write-StatusLine -Key 'status' -Value $status
Write-StatusLine -Key 'key_path' -Value $KeyPath
Write-StatusLine -Key 'public_key_path' -Value $pubPath
Write-StatusLine -Key 'ssh_agent' -Value $agent
Write-StatusLine -Key 'ssh_add' -Value $added
if (-not $Quiet) {
Write-Output ('public_key=' + $publicKey)
}
@@ -0,0 +1,112 @@
# Agent-safe: decrypt the local token and feed it to Git Credential Manager.
# Never prints the token. Never writes it to a file.
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_missing'
exit 1
}
$username = ([string]$gitProfile.gitUsername).Trim()
if ([string]::IsNullOrWhiteSpace($username)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_username_missing'
exit 1
}
if ($username -match "[\r\n]") {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_username_invalid'
exit 1
}
if (-not (Test-Path -LiteralPath $script:TokenPath)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_missing'
exit 1
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
$hostName = [string]$parsed.CredentialHost
if ([string]::IsNullOrWhiteSpace($hostName)) {
$hostName = [string]$parsed.Host
}
if ([string]::IsNullOrWhiteSpace($hostName)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'host_parse_failed'
exit 1
}
$credProtocol = 'https'
if ([string]$parsed.Protocol -eq 'http') {
$credProtocol = 'http'
}
$token = $null
$payload = $null
try {
$data = [System.IO.File]::ReadAllBytes($script:TokenPath)
$token = Unprotect-SecretBytes -Data $data
if ([string]::IsNullOrWhiteSpace($token)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_invalid'
exit 1
}
$payload = @"
protocol=$credProtocol
host=$hostName
username=$username
password=$token
"@
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'git'
$psi.Arguments = 'credential approve'
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
foreach ($line in ($payload -split "`r?`n")) {
$proc.StandardInput.WriteLine($line)
}
$proc.StandardInput.Close()
$null = $proc.StandardOutput.ReadToEnd()
$null = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
if ($proc.ExitCode -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_credential_approve_failed'
exit 1
}
}
catch {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'inject_error'
exit 1
}
finally {
$token = $null
$payload = $null
$data = $null
}
Write-StatusLine -Key 'status' -Value 'injected'
Write-StatusLine -Key 'host' -Value $hostName
Write-StatusLine -Key 'protocol' -Value $credProtocol
Write-StatusLine -Key 'username' -Value $username
Write-Output 'token=hidden'
@@ -0,0 +1,74 @@
# Agent-safe: refresh HTTPS/SSH auth for a remote URL. Never prints the token.
# Does not require being inside a git work tree (used by clone).
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl,
[string]$RemoteName = ''
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if ($RemoteUrl -match '@.+@' -or $RemoteUrl -match '://[^/:]+:[^@/]+@') {
$safeUrl = '<redacted-embedded-credentials>'
} else {
$safeUrl = $RemoteUrl.Trim()
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
$protocol = [string]$parsed.Protocol
$hostName = [string]$parsed.Host
$helper = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', '--get', 'credential.helper') | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($helper)) {
Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', 'credential.helper', 'manager') | Out-Null
$helper = 'manager'
}
$tokenPresent = Test-Path -LiteralPath $script:TokenPath
$inject = 'skipped'
if ($tokenPresent) {
& (Join-Path $PSScriptRoot 'inject-credential.ps1') -RemoteUrl $RemoteUrl
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'inject_failed'
exit 1
}
$inject = 'ok'
}
elseif ($protocol -eq 'http' -or $protocol -eq 'https') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_missing'
exit 1
}
$ssh = 'skipped'
if ($protocol -eq 'ssh') {
$email = ''
$gitProfile = Get-GitSkillsProfile
if ($gitProfile) { $email = [string]$gitProfile.userEmail }
& (Join-Path $PSScriptRoot 'ensure-ssh.ps1') -Email $email -NoGenerate -Quiet
if ($LASTEXITCODE -ne 0) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'ssh_failed'
exit 1
}
$ssh = 'ok'
}
Write-StatusLine -Key 'status' -Value 'ready'
if (-not [string]::IsNullOrWhiteSpace($RemoteName)) {
Write-StatusLine -Key 'remote' -Value $RemoteName
}
Write-StatusLine -Key 'url' -Value $safeUrl
Write-StatusLine -Key 'protocol' -Value $protocol
Write-StatusLine -Key 'host' -Value $hostName
if ($null -ne $parsed.Port) {
Write-StatusLine -Key 'port' -Value "$($parsed.Port)"
}
Write-StatusLine -Key 'credential_host' -Value ([string]$parsed.CredentialHost)
Write-StatusLine -Key 'credential_helper' -Value $helper
Write-StatusLine -Key 'inject' -Value $inject
Write-StatusLine -Key 'ssh' -Value $ssh
Write-Output 'token=hidden'
@@ -0,0 +1,57 @@
# Agent-safe: apply local author + signing from profile. Does not commit.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_missing'
exit 1
}
$userName = ([string]$gitProfile.userName).Trim()
$userEmail = ([string]$gitProfile.userEmail).Trim()
if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($userEmail)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'profile_incomplete'
exit 1
}
& git config user.name $userName
& git config user.email $userEmail
& git config commit.gpgsign true
# This skill defaults to OpenPGP signing for Gitea verification (not gpg.format=ssh).
$fmt = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'gpg.format') | Out-String).Trim()
if ($fmt -eq 'ssh') {
Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--unset', 'gpg.format') | Out-Null
}
$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim()
$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim()
$signing = 'missing'
if (-not [string]::IsNullOrWhiteSpace($signingKey)) {
$signing = 'present'
}
Write-StatusLine -Key 'status' -Value 'ready'
Write-StatusLine -Key 'userName' -Value $userName
Write-StatusLine -Key 'userEmail' -Value $userEmail
Write-StatusLine -Key 'signing' -Value $signing
Write-StatusLine -Key 'signingkey' -Value $signingKey
Write-StatusLine -Key 'gpgsign' -Value $gpgSign
$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
exit 0
@@ -0,0 +1,26 @@
# Agent-safe: refresh auth for an existing remote (default origin).
param(
[string]$Remote = 'origin'
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'not_a_git_repo'
exit 1
}
$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', $Remote) | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'remote_missing'
Write-StatusLine -Key 'remote' -Value $Remote
exit 1
}
& (Join-Path $PSScriptRoot 'prepare-auth.ps1') -RemoteUrl $remoteUrl -RemoteName $Remote
exit $LASTEXITCODE
@@ -0,0 +1,23 @@
# Agent-safe: print local author profile fields. Never touches the token.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$gitProfile = Get-GitSkillsProfile
if ($null -eq $gitProfile) {
Write-StatusLine -Key 'profile' -Value 'missing'
exit 0
}
Write-StatusLine -Key 'profile' -Value 'present'
Write-StatusLine -Key 'path' -Value $script:ProfilePath
Write-StatusLine -Key 'userName' -Value ([string]$gitProfile.userName)
Write-StatusLine -Key 'userEmail' -Value ([string]$gitProfile.userEmail)
Write-StatusLine -Key 'gitUsername' -Value ([string]$gitProfile.gitUsername)
Write-StatusLine -Key 'sshKeyPath' -Value ([string]$gitProfile.sshKeyPath)
$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
@@ -0,0 +1,52 @@
# Agent-safe repo snapshot. Never prints tokens or private keys.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') {
Write-StatusLine -Key 'inside' -Value 'false'
exit 0
}
$branch = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('branch', '--show-current') | Out-String).Trim()
$head = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--short', 'HEAD') | Out-String).Trim()
$upstream = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Out-String).Trim()
if ($LASTEXITCODE -ne 0) { $upstream = '' }
$porcelain = @(Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain'))
$dirty = if ($porcelain.Count -gt 0) { 'yes' } else { 'no' }
$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', 'origin') | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) {
$remoteUrl = ''
$safeUrl = ''
$protocol = ''
} else {
if ($remoteUrl -match '@.+@' -or $remoteUrl -match '://[^/:]+:[^@/]+@') {
$safeUrl = '<redacted-embedded-credentials>'
} else {
$safeUrl = $remoteUrl
}
$parsed = Parse-GitRemoteUrl -RemoteUrl $remoteUrl
$protocol = [string]$parsed.Protocol
}
$userName = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.name') | Out-String).Trim()
$userEmail = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.email') | Out-String).Trim()
$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim()
$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim()
Write-StatusLine -Key 'inside' -Value 'true'
Write-StatusLine -Key 'branch' -Value $branch
Write-StatusLine -Key 'head' -Value $head
Write-StatusLine -Key 'upstream' -Value $upstream
Write-StatusLine -Key 'dirty' -Value $dirty
Write-StatusLine -Key 'origin_url' -Value $safeUrl
Write-StatusLine -Key 'protocol' -Value $protocol
Write-StatusLine -Key 'userName' -Value $userName
Write-StatusLine -Key 'userEmail' -Value $userEmail
Write-StatusLine -Key 'gpgsign' -Value $gpgSign
Write-StatusLine -Key 'signingkey' -Value $signingKey
Write-Output 'token=hidden'
exit 0
@@ -0,0 +1,106 @@
# Interactive: write local author profile (not a secret).
# Run in the user's own terminal, not in an agent session.
param(
[string]$UserName,
[string]$UserEmail,
[string]$GitUsername,
[string]$SshKeyPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$defaults = Get-GitSkillsDefaults
function Read-DefaultPrompt {
param(
[string]$Label,
[string]$Default
)
if ([string]::IsNullOrWhiteSpace($Default)) {
$value = Read-Host $Label
return $value.Trim()
}
$value = Read-Host "$Label [$Default]"
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
}
return $value.Trim()
}
$existing = Get-GitSkillsProfile
# Do not assign to $profile - that is a PowerShell automatic variable.
if (-not $PSBoundParameters.ContainsKey('UserName') -or [string]::IsNullOrWhiteSpace($UserName)) {
$fallback = if ($existing) { $existing.userName } else { $defaults.userName }
$UserName = Read-DefaultPrompt -Label 'Git user.name' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('UserEmail') -or [string]::IsNullOrWhiteSpace($UserEmail)) {
$fallback = if ($existing) { $existing.userEmail } else { $defaults.userEmail }
$UserEmail = Read-DefaultPrompt -Label 'Git user.email' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('GitUsername') -or [string]::IsNullOrWhiteSpace($GitUsername)) {
$fallback = if ($existing) { $existing.gitUsername } else { '' }
$GitUsername = Read-DefaultPrompt -Label 'Gitea username (HTTPS login name, not the token)' -Default $fallback
}
if (-not $PSBoundParameters.ContainsKey('SshKeyPath') -or [string]::IsNullOrWhiteSpace($SshKeyPath)) {
$fallback = if ($existing -and $existing.sshKeyPath) {
$existing.sshKeyPath
} else {
Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
$SshKeyPath = Read-DefaultPrompt -Label 'SSH private key path' -Default $fallback
}
$coAuthors = @()
if ($existing -and $existing.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $existing.coAuthors) {
$coAuthors = @($existing.coAuthors)
}
$extraCo = ''
if (-not [Console]::IsInputRedirected) {
$extraCo = Read-DefaultPrompt -Label 'Extra Co-authored-by as Name <email> (empty keeps current list)' -Default ''
}
if ($extraCo -match '^\s*(.+?)\s*<([^>]+)>\s*$') {
$entry = [pscustomobject]@{
name = $Matches[1].Trim()
email = $Matches[2].Trim()
}
$dup = $false
foreach ($c in $coAuthors) {
if (([string]$c.name) -eq $entry.name -and ([string]$c.email) -eq $entry.email) {
$dup = $true
break
}
}
if (-not $dup) {
$coAuthors += $entry
}
}
if ([string]::IsNullOrWhiteSpace($UserName)) { throw 'user.name is required.' }
if ([string]::IsNullOrWhiteSpace($UserEmail)) { throw 'user.email is required.' }
if ([string]::IsNullOrWhiteSpace($GitUsername)) { throw 'HTTPS Git username is required.' }
$profileObj = [pscustomobject]@{
userName = $UserName
userEmail = $UserEmail
gitUsername = $GitUsername
sshKeyPath = $SshKeyPath
coAuthors = @($coAuthors)
updatedAtUtc = [DateTime]::UtcNow.ToString('o')
}
Save-GitSkillsProfile -Profile $profileObj
Write-StatusLine -Key 'status' -Value 'saved'
Write-StatusLine -Key 'path' -Value $script:ProfilePath
Write-StatusLine -Key 'userName' -Value $UserName
Write-StatusLine -Key 'userEmail' -Value $UserEmail
Write-StatusLine -Key 'gitUsername' -Value $GitUsername
Write-StatusLine -Key 'sshKeyPath' -Value $SshKeyPath
$trailers = Get-CoAuthorTrailerLines -GitProfile $profileObj
Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)"
foreach ($line in $trailers) {
Write-StatusLine -Key 'co_authored_by' -Value $line
}
@@ -0,0 +1,42 @@
# Interactive: encrypt a Git token with Windows DPAPI (CurrentUser).
# Run in the user's own terminal. Never print the token.
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
Ensure-GitSkillsHome
if ((Test-Path -LiteralPath $script:TokenPath) -and -not $Force) {
$answer = Read-Host 'Encrypted token already exists. Overwrite? (y/N)'
if ($answer -notmatch '^[Yy]$') {
Write-StatusLine -Key 'status' -Value 'unchanged'
exit 0
}
}
$secure = Read-Host 'Paste Gitea access token (input hidden)' -AsSecureString
if ($secure.Length -lt 1) {
throw 'Token is empty.'
}
$plain = $null
try {
$plain = ConvertFrom-SecureStringPlain -Secure $secure
if ([string]::IsNullOrWhiteSpace($plain)) {
throw 'Token is empty.'
}
$protected = Protect-SecretString -PlainText $plain
[System.IO.File]::WriteAllBytes($script:TokenPath, $protected)
}
finally {
$plain = $null
$protected = $null
}
Write-StatusLine -Key 'status' -Value 'saved'
Write-StatusLine -Key 'path' -Value $script:TokenPath
Write-Output 'token=hidden'
@@ -0,0 +1,66 @@
# Agent-safe: test SSH auth against a remote (Gitea-friendly). Never prints private keys.
param(
[Parameter(Mandatory = $true)]
[string]$RemoteUrl
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl
if ([string]$parsed.Protocol -ne 'ssh') {
Write-StatusLine -Key 'status' -Value 'skipped'
Write-StatusLine -Key 'reason' -Value 'not_ssh_remote'
Write-StatusLine -Key 'protocol' -Value ([string]$parsed.Protocol)
exit 0
}
$hostName = [string]$parsed.Host
$port = $parsed.Port
$sshArgs = @()
if ($null -ne $port -and [int]$port -ne 22) {
$sshArgs += @('-p', "$port")
}
$sshArgs += @('-o', 'BatchMode=yes', '-T', "git@$hostName")
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & ssh @sshArgs 2>&1 | Out-String
$code = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $prevEap
}
$text = if ($null -eq $output) { '' } else { [string]$output }
$ok = $false
if ($text -match 'successfully authenticated' -or
$text -match 'Hi there,' -or
$text -match 'Welcome to Gitea' -or
$text -match "You've successfully authenticated") {
$ok = $true
}
# Gitea/GitHub often exit 1 even when auth succeeded (no shell).
if ($ok) {
Write-StatusLine -Key 'status' -Value 'ok'
} else {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'exit_code' -Value "$code"
}
Write-StatusLine -Key 'host' -Value $hostName
if ($null -ne $port) {
Write-StatusLine -Key 'port' -Value "$port"
} else {
Write-StatusLine -Key 'port' -Value '22'
}
# Safe to show SSH server greeting; strip nothing secret-bearing beyond pubkey auth result.
$oneLine = ($text -replace '\r?\n', ' ').Trim()
if ($oneLine.Length -gt 240) { $oneLine = $oneLine.Substring(0, 240) }
Write-StatusLine -Key 'message' -Value $oneLine
if (-not $ok) { exit 1 }
exit 0
@@ -0,0 +1,34 @@
# Agent-safe: report whether an encrypted token exists. Never prints it.
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if (-not (Test-Path -LiteralPath $script:TokenPath)) {
Write-StatusLine -Key 'token' -Value 'missing'
exit 0
}
$info = Get-Item -LiteralPath $script:TokenPath
if ($info.Length -lt 1) {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
try {
$data = [System.IO.File]::ReadAllBytes($script:TokenPath)
$plain = Unprotect-SecretBytes -Data $data
if ([string]::IsNullOrWhiteSpace($plain)) {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
}
catch {
Write-StatusLine -Key 'token' -Value 'invalid'
exit 0
}
finally {
$plain = $null
$data = $null
}
Write-StatusLine -Key 'token' -Value 'present'
@@ -0,0 +1,109 @@
# Orchestrate Gitea SSH + GPG key verification challenges.
# Challenge tokens come from /user/settings/keys Verify page (short-lived).
# Never store them. Never confuse with the HTTPS access token in token.dpapi.
param(
[string]$GpgToken,
[string]$SshToken,
[string]$KeyId,
[string]$KeyPath,
[switch]$GpgOnly,
[switch]$SshOnly
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-DefaultGpgKeyId {
$ensure = Join-Path $PSScriptRoot 'ensure-gpg.ps1'
$gitProfile = Get-GitSkillsProfile
$name = if ($gitProfile) { [string]$gitProfile.userName } else { '' }
$email = if ($gitProfile) { [string]$gitProfile.userEmail } else { '' }
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File $ensure -Name $name -Email $email 2>&1 | Out-String
}
finally {
$ErrorActionPreference = $prev
}
if ($out -match 'key_id=([0-9A-Fa-f]+)') {
return $Matches[1]
}
return $null
}
$doGpg = -not $SshOnly
$doSsh = -not $GpgOnly
if (-not $doGpg -and -not $doSsh) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'nothing_to_verify'
exit 1
}
$failed = $false
if ($doGpg) {
if ([string]::IsNullOrWhiteSpace($GpgToken)) {
Write-StatusLine -Key 'gpg' -Value 'need_token'
Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > GPG Verify, paste the page challenge token here (NOT the HTTPS access token)'
}
else {
if ([string]::IsNullOrWhiteSpace($KeyId)) {
$KeyId = Resolve-DefaultGpgKeyId
}
if ([string]::IsNullOrWhiteSpace($KeyId)) {
Write-StatusLine -Key 'gpg' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_key_id_missing'
$failed = $true
}
else {
Write-StatusLine -Key 'gpg_key_id' -Value $KeyId
& (Join-Path $PSScriptRoot 'verify-gpg-challenge.ps1') -Token $GpgToken -KeyId $KeyId
if ($LASTEXITCODE -ne 0) {
$failed = $true
}
else {
Write-Output 'gpg_next=Paste the BEGIN/END PGP SIGNATURE block back into Gitea GPG Verify, then click Verify'
}
}
}
}
if ($doSsh) {
if ([string]::IsNullOrWhiteSpace($SshToken)) {
Write-StatusLine -Key 'ssh' -Value 'need_token'
Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > SSH Verify, paste that page challenge token here (different from GPG token)'
}
else {
$sshInvoke = @{
Token = $SshToken
}
if (-not [string]::IsNullOrWhiteSpace($KeyPath)) {
$sshInvoke['KeyPath'] = $KeyPath
}
& (Join-Path $PSScriptRoot 'verify-ssh-challenge.ps1') @sshInvoke
if ($LASTEXITCODE -ne 0) {
$failed = $true
}
else {
Write-Output 'ssh_next=Paste the BEGIN/END SSH SIGNATURE block back into Gitea SSH Verify, then click Verify'
}
}
}
if ($failed) {
Write-StatusLine -Key 'status' -Value 'failed'
exit 1
}
$gpgPending = $doGpg -and [string]::IsNullOrWhiteSpace($GpgToken)
$sshPending = $doSsh -and [string]::IsNullOrWhiteSpace($SshToken)
if ($gpgPending -or $sshPending) {
Write-StatusLine -Key 'status' -Value 'waiting_for_tokens'
exit 0
}
Write-StatusLine -Key 'status' -Value 'signatures_ready'
Write-Output 'done=Signatures generated. After user pastes them on Gitea and clicks Verify, check new commits for verified status.'
exit 0
@@ -0,0 +1,98 @@
# Generate a Gitea GPG key-verification signature (official UI challenge).
# Token comes from /user/settings/keys -> Verify. Never commit the token.
# Matches Gitea template: echo "TOKEN" | gpg -a --default-key KEYID --detach-sig
# (trailing newline included - unlike SSH verification).
param(
[Parameter(Mandatory = $true)]
[string]$Token,
[Parameter(Mandatory = $true)]
[string]$KeyId,
[string]$GpgPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
function Resolve-GpgPath {
param([string]$Preferred)
if ($Preferred -and (Test-Path -LiteralPath $Preferred)) { return $Preferred }
$cmd = Get-Command gpg -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$candidates = @(
(Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'),
(Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe')
)
foreach ($p in $candidates) {
if ($p -and (Test-Path -LiteralPath $p)) { return $p }
}
return $null
}
$gpg = Resolve-GpgPath -Preferred $GpgPath
if ($null -eq $gpg) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'gpg_missing'
exit 1
}
$token = $Token.Trim()
$keyId = $KeyId.Trim()
if ([string]::IsNullOrWhiteSpace($token) -or [string]::IsNullOrWhiteSpace($keyId)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_or_key_empty'
exit 1
}
$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe'
$sigText = $null
# gpg prints "using ... as default secret key" to stderr; do not treat as terminating error.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
if (Test-Path -LiteralPath $bash) {
# Unix echo "TOKEN" includes a single trailing LF - matches Gitea UI help.
$sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "gpg -a --default-key '$keyId' --detach-sig") -InputText ($token + "`n") | Out-String
} else {
$tmp = [System.IO.Path]::GetTempFileName()
try {
# UTF-8 token + LF, no BOM
$bytes = [System.Text.Encoding]::UTF8.GetBytes($token + "`n")
[System.IO.File]::WriteAllBytes($tmp, $bytes)
$null = Invoke-GitSkillsNative -FilePath $gpg -ArgumentList @('-a', '--default-key', $keyId, '--detach-sig', $tmp)
$asc = $tmp + '.asc'
if (Test-Path -LiteralPath $asc) {
$sigText = Get-Content -LiteralPath $asc -Raw -Encoding UTF8
Remove-Item -LiteralPath $asc -Force -ErrorAction SilentlyContinue
}
}
finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
}
finally {
$ErrorActionPreference = $prevEap
}
if ($sigText -notmatch 'BEGIN PGP SIGNATURE') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'sign_failed'
Write-StatusLine -Key 'gpg' -Value $gpg
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
Write-StatusLine -Key 'key_id' -Value $keyId
Write-StatusLine -Key 'gpg' -Value $gpg
Write-Output '-----BEGIN_SIGNATURE_BODY-----'
# Print only the armored block lines for easy copy
$capture = $false
foreach ($line in ($sigText -split "`r?`n")) {
if ($line -match 'BEGIN PGP SIGNATURE') { $capture = $true }
if ($capture) { Write-Output $line }
if ($line -match 'END PGP SIGNATURE') { break }
}
Write-Output '-----END_SIGNATURE_BODY-----'
Write-Output 'Paste the block between BEGIN/END PGP SIGNATURE into Gitea Verify, then click verify.'
@@ -0,0 +1,81 @@
# Generate a Gitea SSH key-verification signature (official UI challenge).
# Token from /user/settings/keys -> Verify. Never commit the token.
# Matches: echo -n 'TOKEN' | ssh-keygen -Y sign -n gitea -f KEY
param(
[Parameter(Mandatory = $true)]
[string]$Token,
[string]$KeyPath
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'common.ps1')
if ([string]::IsNullOrWhiteSpace($KeyPath)) {
$gitProfile = Get-GitSkillsProfile
if ($gitProfile -and $gitProfile.sshKeyPath) {
$KeyPath = [string]$gitProfile.sshKeyPath
} else {
$KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519'
}
}
$token = $Token.Trim()
if ([string]::IsNullOrWhiteSpace($token)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'token_empty'
exit 1
}
if (-not (Test-Path -LiteralPath $KeyPath)) {
$pub = $KeyPath + '.pub'
if (Test-Path -LiteralPath $pub) {
$KeyPath = $pub
} else {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'key_missing'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
}
$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe'
if (-not (Test-Path -LiteralPath $bash)) {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'git_bash_missing'
Write-Output 'hint=Install Git for Windows, or run the echo -n | ssh-keygen command from the Gitea page in Git Bash'
exit 1
}
# Convert Windows path to Git Bash path: C:\Users\... -> /c/Users/...
$full = [System.IO.Path]::GetFullPath($KeyPath)
$drive = $full.Substring(0, 1).ToLower()
$rest = $full.Substring(2) -replace '\\', '/'
$unixKey = "/$drive$rest"
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "ssh-keygen -Y sign -n gitea -f '$unixKey'") -InputText $token | Out-String
}
finally {
$ErrorActionPreference = $prevEap
}
if ($sigText -notmatch 'BEGIN SSH SIGNATURE') {
Write-StatusLine -Key 'status' -Value 'failed'
Write-StatusLine -Key 'reason' -Value 'sign_failed'
Write-StatusLine -Key 'key_path' -Value $KeyPath
exit 1
}
Write-StatusLine -Key 'status' -Value 'ok'
Write-StatusLine -Key 'key_path' -Value $KeyPath
Write-Output '-----BEGIN_SIGNATURE_BODY-----'
$capture = $false
foreach ($line in ($sigText -split "`r?`n")) {
if ($line -match 'BEGIN SSH SIGNATURE') { $capture = $true }
if ($capture) { Write-Output $line }
if ($line -match 'END SSH SIGNATURE') { break }
}
Write-Output '-----END_SIGNATURE_BODY-----'
Write-Output 'Paste the SSH SIGNATURE block into Gitea Verify, then click verify.'