scripts/internal/generate-shell-wrappers.ps1
|
#!/usr/bin/env pwsh <# .SYNOPSIS Generate the committed POSIX sh wrapper commands in bin/ from the canonical Specrew command registry (Specrew.psd1 AliasesToExport + root `specrew`). .DESCRIPTION Single source of truth for the Unix wrapper set (feature 140 / Proposal 153, generate-then-commit). Each wrapper is a THIN POSIX sh forwarder: verify pwsh, resolve the module root (following symlinks so a symlink from ~/.local/bin still finds the module), then exec the root dispatcher scripts/specrew.ps1 with the alias's subcommand prepended. No shell-side option parsing beyond alias->subcommand dispatch — all option contracts stay in PowerShell. Output is deterministic and idempotent (byte-identical re-run) and is always written with LF line endings (POSIX sh requires LF; .gitattributes pins bin/* to eol=lf so core.autocrlf cannot corrupt the shebang). .PARAMETER RepoRoot Module/repo root. Defaults to two parents up from this script (scripts/internal/..). .PARAMETER Check Do not write. Regenerate in memory and compare to the committed bin/ files; exit 1 on any drift (missing, changed, or extra wrapper), 0 when in sync. Used by CI to enforce generate-then-commit. #> [CmdletBinding()] param( [string]$RepoRoot, [switch]$Check ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path } $manifestPath = Join-Path $RepoRoot 'Specrew.psd1' $binDir = Join-Path $RepoRoot 'bin' function Get-CommandRegistry { param([Parameter(Mandatory = $true)][string]$ManifestPath) $psd = Import-PowerShellDataFile -LiteralPath $ManifestPath if (-not $psd.ContainsKey('AliasesToExport')) { throw "Specrew.psd1 has no AliasesToExport; cannot derive the wrapper registry." } $entries = foreach ($name in (@($psd.AliasesToExport) | Sort-Object)) { if ($name -eq 'specrew') { [pscustomobject]@{ Name = 'specrew'; Subcommand = '' } } elseif ($name -like 'specrew-*') { [pscustomobject]@{ Name = $name; Subcommand = $name.Substring('specrew-'.Length) } } else { throw "Unrecognized alias '$name' (expected 'specrew' or 'specrew-<subcommand>')." } } return @($entries) } function New-WrapperContent { param([AllowEmptyString()][Parameter(Mandatory = $true)][string]$Subcommand) $execLine = if ([string]::IsNullOrEmpty($Subcommand)) { 'exec pwsh -NoProfile -ExecutionPolicy Bypass -File "$specrew_module_root/scripts/specrew.ps1" "$@"' } else { 'exec pwsh -NoProfile -ExecutionPolicy Bypass -File "$specrew_module_root/scripts/specrew.ps1" ' + $Subcommand + ' "$@"' } # Each element is a single-quoted literal so PowerShell never interpolates the # shell's $0 / $@ / $specrew_* tokens. Joined with LF and given a trailing LF. $lines = @( '#!/usr/bin/env sh' '# Specrew Unix wrapper - GENERATED by scripts/internal/generate-shell-wrappers.ps1.' '# Do not edit by hand; re-run the generator. (generate-then-commit; CI fails on drift.)' '# Thin forwarder: verify pwsh, resolve the module root via symlinks, exec the PowerShell CLI.' 'set -eu' '' 'if ! command -v pwsh >/dev/null 2>&1; then' ' echo "specrew: PowerShell Core (pwsh) is required but was not found on PATH." >&2' ' echo "Install PowerShell Core (https://aka.ms/powershell), then re-run." >&2' ' exit 127' 'fi' '' '# Resolve this script''s real location, following symlinks (POSIX sh; no GNU readlink -f).' 'specrew_self="$0"' 'while [ -L "$specrew_self" ]; do' ' specrew_link="$(readlink "$specrew_self")"' ' case "$specrew_link" in' ' /*) specrew_self="$specrew_link" ;;' ' *) specrew_self="$(dirname "$specrew_self")/$specrew_link" ;;' ' esac' 'done' 'specrew_bindir="$(cd "$(dirname "$specrew_self")" && pwd)"' 'specrew_module_root="$(cd "$specrew_bindir/.." && pwd)"' '' $execLine ) return (($lines -join "`n") + "`n") } $registry = Get-CommandRegistry -ManifestPath $manifestPath if ($Check) { $drift = New-Object System.Collections.Generic.List[string] foreach ($entry in $registry) { $expected = New-WrapperContent -Subcommand $entry.Subcommand $path = Join-Path $binDir $entry.Name if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { $drift.Add("missing: bin/$($entry.Name)") continue } # Normalize CRLF->LF before comparing so a mis-checked-out file reports as # drift on its content, not spuriously on line endings. $actual = ([System.IO.File]::ReadAllText($path)) -replace "`r`n", "`n" if ($actual -ne $expected) { $drift.Add("drift: bin/$($entry.Name)") } } if (Test-Path -LiteralPath $binDir -PathType Container) { $registryNames = @($registry | ForEach-Object { $_.Name }) foreach ($file in (Get-ChildItem -LiteralPath $binDir -File)) { if ($file.Name -notin $registryNames) { $drift.Add("extra: bin/$($file.Name) (no matching registry alias)") } } } if ($drift.Count -gt 0) { Write-Host "Wrapper drift detected (cascade: registry -> wrappers -> installer -> FileList -> docs):" -ForegroundColor Red foreach ($d in $drift) { Write-Host " $d" } Write-Host "Re-run: pwsh -File scripts/internal/generate-shell-wrappers.ps1" -ForegroundColor Yellow exit 1 } Write-Host "Wrappers in sync with the command registry ($($registry.Count) commands)." -ForegroundColor Green exit 0 } if (-not (Test-Path -LiteralPath $binDir -PathType Container)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null } foreach ($entry in $registry) { $content = New-WrapperContent -Subcommand $entry.Subcommand $path = Join-Path $binDir $entry.Name [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) Write-Host "generated bin/$($entry.Name)" } Write-Host "Generated $($registry.Count) wrapper(s) in $binDir." -ForegroundColor Green exit 0 |