@@ -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
|
||||
@@ -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.'
|
||||
Reference in New Issue
Block a user