extensions/specrew-speckit/scripts/validate-versions.ps1
|
# validate-versions.ps1 # Validates Spec Kit and Squad versions meet Specrew minimum requirements <# .SYNOPSIS Validates platform version compatibility for Specrew. .DESCRIPTION Checks that Spec Kit and Squad are installed at versions compatible with Specrew. Required versions default to Spec Kit >= 0.8.4 and Squad >= 0.9.1. .PARAMETER SpecKitVersion Installed Spec Kit version (optional, auto-detected if omitted). .PARAMETER SquadVersion Installed Squad version (optional, auto-detected if omitted). .PARAMETER MinimumSpecKitVersion Minimum required Spec Kit version. .PARAMETER MinimumSquadVersion Minimum required Squad version. .PARAMETER PassThru Return structured validation results instead of exiting directly. .EXAMPLE .\validate-versions.ps1 .EXAMPLE .\validate-versions.ps1 -PassThru #> [CmdletBinding()] param( [string]$SpecKitVersion, [string]$SquadVersion, [string]$MinimumSpecKitVersion = '0.8.4', [string]$MinimumSquadVersion = '0.9.1', [switch]$PassThru ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-NativeExitCode { if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { return $global:LASTEXITCODE } return 0 } function Get-ParsedVersion { param( [Parameter(Mandatory = $true)] [string]$Value, [Parameter(Mandatory = $true)] [string]$Name ) $match = [regex]::Match($Value, '(?<version>\d+\.\d+\.\d+(?:\.\d+)?)') if (-not $match.Success) { throw "Could not parse $Name version from '$Value'." } return [version]$match.Groups['version'].Value } function Get-VersionOutputLine { param( [AllowEmptyCollection()] [string[]]$OutputLines, [string]$PreferredPattern ) foreach ($line in @($OutputLines)) { if (-not $line) { continue } if ($PreferredPattern -and $line -notmatch $PreferredPattern) { continue } if ([regex]::Match($line, '\d+\.\d+\.\d+(?:\.\d+)?').Success) { return [string]$line } } foreach ($line in @($OutputLines)) { if ($line -and [regex]::Match($line, '\d+\.\d+\.\d+(?:\.\d+)?').Success) { return [string]$line } } return $null } function Get-UvToolVersion { param( [Parameter(Mandatory = $true)] [string]$ToolName, [Parameter(Mandatory = $true)] [string]$PackageName ) if (-not (Get-Command -Name 'uv' -ErrorAction SilentlyContinue)) { return $null } $uvOutput = @(& uv tool list 2>&1) if ((Get-NativeExitCode) -ne 0) { return $null } $preferredPattern = '^(?:{0}|{1})\b' -f [regex]::Escape($PackageName), [regex]::Escape($ToolName) return Get-VersionOutputLine -OutputLines $uvOutput -PreferredPattern $preferredPattern } function Get-SpecKitGitReference { param( [Parameter(Mandatory = $true)] [string]$Version ) $trimmedVersion = $Version.Trim() if ([string]::IsNullOrWhiteSpace($trimmedVersion)) { throw 'Spec Kit version cannot be empty.' } if ($trimmedVersion.StartsWith('v', [System.StringComparison]::OrdinalIgnoreCase)) { return $trimmedVersion } return ('v{0}' -f $trimmedVersion) } function Get-SpecKitInstallCommandText { param( [Parameter(Mandatory = $true)] [string]$Version, [Parameter(Mandatory = $true)] [bool]$ForceInstall ) $arguments = @('tool', 'install') if ($ForceInstall) { $arguments += '--force' } $arguments += @( 'specify-cli', '--from', ('git+https://github.com/github/spec-kit.git@{0}' -f (Get-SpecKitGitReference -Version $Version)) ) return ('uv {0}' -f ($arguments -join ' ')) } function Get-VersionProbePlan { param( [Parameter(Mandatory = $true)] [string]$CommandName ) switch ($CommandName) { 'specify' { return @( [pscustomobject]@{ ArgumentList = @('--version') PreferredPattern = ('^{0}\b' -f [regex]::Escape($CommandName)) VersionFrom = 'command' Environment = @{} }, [pscustomobject]@{ ArgumentList = @('version') PreferredPattern = 'CLI Version' VersionFrom = 'command:version' Environment = @{ PYTHONIOENCODING = 'utf-8' } } ) } default { return @( [pscustomobject]@{ ArgumentList = @('--version') PreferredPattern = ('^{0}\b' -f [regex]::Escape($CommandName)) VersionFrom = 'command' Environment = @{} } ) } } } function Invoke-VersionProbe { param( [Parameter(Mandatory = $true)] [string]$CommandName, [Parameter(Mandatory = $true)] [string[]]$ArgumentList, [hashtable]$Environment = @{} ) $rawOutput = @() $probeError = $null $originalEnvironment = @{} foreach ($entry in $Environment.GetEnumerator()) { $originalEnvironment[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, 'Process') [Environment]::SetEnvironmentVariable($entry.Key, [string]$entry.Value, 'Process') } try { try { $rawOutput = @(& $CommandName @ArgumentList 2>&1) } catch { $probeError = $_.Exception.Message $rawOutput = @([string]$probeError) } } finally { foreach ($entry in $Environment.GetEnumerator()) { [Environment]::SetEnvironmentVariable($entry.Key, $originalEnvironment[$entry.Key], 'Process') } } $exitCode = Get-NativeExitCode if (-not $probeError -and $exitCode -ne 0) { $probeError = ($rawOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1) } return [pscustomobject]@{ Output = @($rawOutput | ForEach-Object { [string]$_ }) ExitCode = $exitCode ProbeError = if ($probeError) { [string]$probeError } else { $null } } } function Get-CommandVersion { param( [Parameter(Mandatory = $true)] [string]$CommandName ) $command = Get-Command -Name $CommandName -ErrorAction SilentlyContinue if (-not $command) { return [pscustomobject]@{ RawVersion = $null ProbeError = $null VersionFrom = $null } } $probeError = $null $rawVersion = $null $lastProbeOutput = @() foreach ($probe in @(Get-VersionProbePlan -CommandName $CommandName)) { $probeResult = Invoke-VersionProbe -CommandName $CommandName -ArgumentList $probe.ArgumentList -Environment $probe.Environment $lastProbeOutput = @($probeResult.Output) $probeVersion = Get-VersionOutputLine -OutputLines $probeResult.Output -PreferredPattern $probe.PreferredPattern if ($probeVersion -and $probeResult.ExitCode -eq 0) { return [pscustomobject]@{ RawVersion = [string]$probeVersion ProbeError = $null VersionFrom = [string]$probe.VersionFrom } } if (-not $rawVersion -and $probeVersion) { $rawVersion = [string]$probeVersion } if (-not $probeError -and $probeResult.ProbeError) { $probeError = [string]$probeResult.ProbeError } } if ($CommandName -eq 'specify') { $uvToolVersion = Get-UvToolVersion -ToolName 'specify' -PackageName 'specify-cli' if ($uvToolVersion) { return [pscustomobject]@{ RawVersion = [string]$uvToolVersion ProbeError = if ($probeError) { [string]$probeError } else { 'specify did not return a parseable version from its command surfaces' } VersionFrom = 'uv-tool-list' } } } if (-not $probeError) { $probeError = ($lastProbeOutput | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1) } return [pscustomobject]@{ RawVersion = if ($rawVersion) { [string]$rawVersion } else { [string]($lastProbeOutput | Select-Object -First 1) } ProbeError = if ($probeError) { [string]$probeError } else { $null } VersionFrom = 'command' } } function Get-ValidationResult { param( [Parameter(Mandatory = $true)] [string]$Platform, [Parameter(Mandatory = $true)] [string]$CommandName, [Parameter(Mandatory = $true)] [string]$MinimumVersion, [AllowNull()] [pscustomobject]$Detection, [Parameter(Mandatory = $true)] [string]$InstallCommand ) $isInstalled = [bool](Get-Command -Name $CommandName -ErrorAction SilentlyContinue) $detectedVersion = if ($Detection) { $Detection.RawVersion } else { $null } $probeError = if ($Detection) { $Detection.ProbeError } else { $null } $versionSource = if ($Detection) { $Detection.VersionFrom } else { $null } $parsedDetectedVersion = $null $isCompatible = $false $versionParseError = $null if ($detectedVersion) { try { $parsedDetectedVersion = Get-ParsedVersion -Value $detectedVersion -Name $Platform $isCompatible = $parsedDetectedVersion -ge (Get-ParsedVersion -Value $MinimumVersion -Name "$Platform minimum") } catch { $versionParseError = $_.Exception.Message } } $isOperational = $isInstalled -and [string]::IsNullOrWhiteSpace($probeError) -and [string]::IsNullOrWhiteSpace($versionParseError) $repairCommand = switch ($Platform) { 'Spec Kit' { Get-SpecKitInstallCommandText -Version $MinimumVersion -ForceInstall $true } 'Squad' { 'npm install -g "@bradygaster/squad-cli@{0}"' -f $MinimumVersion } default { $InstallCommand } } [pscustomobject]@{ Platform = $Platform CommandName = $CommandName IsInstalled = $isInstalled IsOperational = $isOperational RawVersion = $detectedVersion Version = if ($parsedDetectedVersion) { $parsedDetectedVersion.ToString() } else { $null } VersionSource = $versionSource ProbeError = $probeError ValidationError = $versionParseError MinimumVersion = $MinimumVersion IsCompatible = $isCompatible SuggestedInstall = $InstallCommand SuggestedUpgrade = $InstallCommand SuggestedRepair = $repairCommand } } $resolvedSpecKitVersion = if ($PSBoundParameters.ContainsKey('SpecKitVersion')) { [pscustomobject]@{ RawVersion = $SpecKitVersion ProbeError = $null VersionFrom = 'parameter' } } else { Get-CommandVersion -CommandName 'specify' } $resolvedSquadVersion = if ($PSBoundParameters.ContainsKey('SquadVersion')) { [pscustomobject]@{ RawVersion = $SquadVersion ProbeError = $null VersionFrom = 'parameter' } } else { Get-CommandVersion -CommandName 'squad' } $results = @( Get-ValidationResult -Platform 'Spec Kit' ` -CommandName 'specify' ` -MinimumVersion $MinimumSpecKitVersion ` -Detection $resolvedSpecKitVersion ` -InstallCommand (Get-SpecKitInstallCommandText -Version $MinimumSpecKitVersion -ForceInstall $false) Get-ValidationResult -Platform 'Squad' ` -CommandName 'squad' ` -MinimumVersion $MinimumSquadVersion ` -Detection $resolvedSquadVersion ` -InstallCommand ('npm install -g "@bradygaster/squad-cli@{0}"' -f $MinimumSquadVersion) ) if ($PassThru) { $results return } $results | Select-Object Platform, IsInstalled, IsOperational, Version, MinimumVersion, IsCompatible | Format-Table -AutoSize $failures = @($results | Where-Object { (-not $_.IsInstalled) -or (-not $_.IsOperational) -or (-not $_.IsCompatible) }) if ($failures.Count -gt 0) { foreach ($failure in $failures) { if (-not $failure.IsInstalled) { Write-Error ("{0} is not installed. Run '{1}'." -f $failure.Platform, $failure.SuggestedInstall) } elseif (-not $failure.IsOperational) { $failureDetail = if ($failure.ProbeError) { $failure.ProbeError } elseif ($failure.ValidationError) { $failure.ValidationError } else { 'the command did not complete successfully' } Write-Error ("{0} is installed but the '{1}' command is not healthy ({2}). Run '{3}' to repair it." -f $failure.Platform, $failure.CommandName, $failureDetail, $failure.SuggestedRepair) } else { Write-Error ("Specrew requires {0} >= {1} but found {2}. Run '{3}' to upgrade." -f $failure.Platform, $failure.MinimumVersion, $failure.Version, $failure.SuggestedUpgrade) } } exit 1 } Write-Host 'Platform versions are compatible with Specrew.' -ForegroundColor Green exit 0 |