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