scripts/specrew-install-shell-wrappers.ps1

#!/usr/bin/env pwsh
<#
.SYNOPSIS
Install or refresh the Specrew Unix shell wrappers into a user bin directory.
 
.DESCRIPTION
feature 140 / Proposal 153, T007. macOS/Linux only. Creates a SYMLINK in the bin
directory for each committed module wrapper (bin/specrew, bin/specrew-*), pointing
back at the module's bin/. Symlinks (not copies) are required so the wrapper's
symlink-resolution loop locates the installed module root (FR-003); a copy at
~/.local/bin would resolve module_root to ~ and break.
 
Safety (FR-006): never writes outside the requested bin directory; never edits shell
profiles; a missing bin directory requires -Force to create; not-on-PATH is warn-only;
-WhatIf reports without changing anything; an existing non-managed file requires -Force
to overwrite. On Windows it is an explained no-op (the PowerShell module commands work
directly).
 
.PARAMETER BinDir
Target bin directory. Default: $HOME/.local/bin.
 
.PARAMETER Force
Create a missing bin directory, and overwrite an existing non-symlink file at a target.
 
.PARAMETER DryRun
Alias -WhatIf. Report planned actions; change nothing.
#>

[CmdletBinding()]
param(
    [string]$BinDir,
    [switch]$Force,
    [Alias('WhatIf')]
    [switch]$DryRun,
    [switch]$Help,
    [Parameter(ValueFromRemainingArguments = $true)]
    [string[]]$CliArgs
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# --- Pure, testable helpers (dot-sourceable; no side effects) ---

function ConvertFrom-UnixStyleInstallerArgs {
    # Normalize shell-style flags (--bin-dir <v> / --force / --whatif|--dry-run / --help)
    # to the PS parameter values, so the command works typed either way.
    param(
        [string]$BinDir,
        [bool]$Force,
        [bool]$DryRun,
        [bool]$Help,
        [AllowEmptyCollection()][string[]]$CliArgs
    )

    $result = [ordered]@{ BinDir = $BinDir; Force = $Force; DryRun = $DryRun; Help = $Help }
    $args = @($CliArgs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    for ($i = 0; $i -lt $args.Count; $i++) {
        switch -Regex ($args[$i]) {
            '^--bin-dir=(.+)$' { $result.BinDir = $Matches[1] }
            '^--bin-dir$' {
                $i++
                if ($i -ge $args.Count) { throw '--bin-dir requires a value.' }
                $result.BinDir = $args[$i]
            }
            '^(--force|-f)$' { $result.Force = $true }
            '^(--whatif|--dry-run)$' { $result.DryRun = $true }
            '^(--help|-h)$' { $result.Help = $true }
            default { throw ("Unknown argument '{0}'." -f $args[$i]) }
        }
    }
    return [pscustomobject]$result
}

function Resolve-SpecrewBinDir {
    param([AllowNull()][string]$BinDir)

    if ([string]::IsNullOrWhiteSpace($BinDir)) {
        $userHome = if ($env:HOME) { $env:HOME } else { [Environment]::GetFolderPath('UserProfile') }
        return (Join-Path $userHome '.local/bin')
    }
    return $BinDir
}

function Test-DirOnPath {
    param(
        [Parameter(Mandatory = $true)][string]$Dir,
        [AllowNull()][string]$PathValue
    )

    if ([string]::IsNullOrEmpty($PathValue)) { return $false }
    $sep = [System.IO.Path]::PathSeparator
    $normalizedDir = $Dir.TrimEnd('/', '\')
    foreach ($entry in ($PathValue -split [regex]::Escape($sep))) {
        if ([string]::IsNullOrWhiteSpace($entry)) { continue }
        if ($entry.TrimEnd('/', '\') -eq $normalizedDir) { return $true }
    }
    return $false
}

function Get-WrapperInstallPlan {
    # Pure decision logic for one wrapper. Returns an action without touching disk.
    # Existing: 'none' | 'symlink-managed' (already -> our wrapper) | 'symlink-foreign' (-> other target) | 'file'
    # Actions: would-create | create | would-replace-symlink | replace-symlink |
    # skip-needs-force | would-overwrite-file | overwrite-file
    # A MANAGED symlink (already points at our module wrapper) is replaced freely (idempotent).
    # A FOREIGN symlink (points elsewhere) or a regular file is NOT clobbered without -Force.
    param(
        [Parameter(Mandatory = $true)][ValidateSet('none', 'symlink-managed', 'symlink-foreign', 'file')][string]$Existing,
        [bool]$Force,
        [bool]$DryRun
    )

    if ($Existing -eq 'none') {
        return $(if ($DryRun) { 'would-create' } else { 'create' })
    }
    if ($Existing -eq 'symlink-managed') {
        return $(if ($DryRun) { 'would-replace-symlink' } else { 'replace-symlink' })
    }
    # Foreign symlink or regular file at the target: do not clobber a user-owned entry without -Force.
    if (-not $Force) {
        return 'skip-needs-force'
    }
    if ($Existing -eq 'symlink-foreign') {
        return $(if ($DryRun) { 'would-replace-symlink' } else { 'replace-symlink' })
    }
    return $(if ($DryRun) { 'would-overwrite-file' } else { 'overwrite-file' })
}

function Test-IsUnixPlatform {
    # PS7 exposes $IsWindows; fall back to env for older hosts.
    if (Get-Variable -Name IsWindows -Scope Global -ErrorAction SilentlyContinue) {
        return (-not $IsWindows)
    }
    return ($env:OS -ne 'Windows_NT')
}

function Get-ExistingTargetKind {
    # Classify what already exists at $Path, relative to the wrapper we intend to install:
    # 'none' | 'symlink-managed' (symlink already points at $ExpectedTarget) |
    # 'symlink-foreign' (symlink points somewhere else) | 'file' (regular file/dir)
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [string]$ExpectedTarget
    )

    $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
    if ($null -eq $item) { return 'none' }
    $isLink = ($item.LinkType -eq 'SymbolicLink') -or `
        (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq [System.IO.FileAttributes]::ReparsePoint)
    if (-not $isLink) { return 'file' }

    # A symlink is "managed" only if it already points at the exact wrapper we would install;
    # anything else is foreign and must not be clobbered without -Force.
    $actualTarget = $item.Target
    if ($actualTarget -is [array]) { $actualTarget = $actualTarget | Select-Object -First 1 }
    if ([string]::IsNullOrWhiteSpace($ExpectedTarget) -or [string]::IsNullOrWhiteSpace([string]$actualTarget)) {
        return 'symlink-foreign'
    }
    $normActual = try { [System.IO.Path]::GetFullPath([string]$actualTarget) } catch { [string]$actualTarget }
    $normExpected = try { [System.IO.Path]::GetFullPath($ExpectedTarget) } catch { $ExpectedTarget }
    if ($normActual -eq $normExpected) { return 'symlink-managed' }
    return 'symlink-foreign'
}

# --- Main (skipped when the file is dot-sourced for unit testing) ---

function Invoke-SpecrewInstallShellWrappers {
    param(
        [AllowNull()][string]$BinDir,
        [bool]$Force,
        [bool]$DryRun
    )

    $moduleRoot = Split-Path -Parent $PSScriptRoot
    $sourceBin = Join-Path $moduleRoot 'bin'
    if (-not (Test-Path -LiteralPath $sourceBin -PathType Container)) {
        Write-Host "ERROR: module bin/ directory not found at '$sourceBin'. The installed module appears corrupt." -ForegroundColor Red
        return 1
    }

    if (-not (Test-IsUnixPlatform)) {
        Write-Host "specrew install-shell-wrappers is for macOS/Linux." -ForegroundColor Yellow
        Write-Host "On Windows, use the PowerShell module commands directly (e.g. 'specrew version'). No wrappers were installed."
        return 0
    }

    $resolvedBinDir = Resolve-SpecrewBinDir -BinDir $BinDir

    if (-not (Test-Path -LiteralPath $resolvedBinDir -PathType Container)) {
        if ($DryRun) {
            Write-Host "[dry-run] would create bin directory: $resolvedBinDir"
        }
        elseif ($Force) {
            New-Item -ItemType Directory -Path $resolvedBinDir -Force | Out-Null
            Write-Host "created bin directory: $resolvedBinDir"
        }
        else {
            Write-Host "ERROR: bin directory '$resolvedBinDir' does not exist." -ForegroundColor Red
            Write-Host "Re-run with -Force to create it: specrew install-shell-wrappers -BinDir '$resolvedBinDir' -Force" -ForegroundColor Yellow
            return 1
        }
    }

    $wrappers = @(Get-ChildItem -LiteralPath $sourceBin -File)
    $installed = New-Object System.Collections.Generic.List[string]
    $skipped = New-Object System.Collections.Generic.List[string]

    foreach ($wrapper in $wrappers) {
        $link = Join-Path $resolvedBinDir $wrapper.Name
        $existing = Get-ExistingTargetKind -Path $link -ExpectedTarget $wrapper.FullName
        $action = Get-WrapperInstallPlan -Existing $existing -Force:$Force -DryRun:$DryRun

        switch ($action) {
            'skip-needs-force' {
                Write-Warning "skipping $($wrapper.Name): '$link' already exists and is not a Specrew-managed wrapper (a regular file, or a symlink pointing elsewhere); re-run with -Force to overwrite it."
                $skipped.Add($wrapper.Name)
                continue
            }
            { $_ -like 'would-*' } {
                Write-Host "[dry-run] $action $link -> $($wrapper.FullName)"
                $installed.Add($wrapper.Name)
                continue
            }
            default {
                if ($action -in @('replace-symlink', 'overwrite-file')) {
                    Remove-Item -LiteralPath $link -Force
                }
                New-Item -ItemType SymbolicLink -Path $link -Target $wrapper.FullName | Out-Null
                $installed.Add($wrapper.Name)
            }
        }
    }

    if (-not (Test-DirOnPath -Dir $resolvedBinDir -PathValue $env:PATH)) {
        Write-Warning "$resolvedBinDir is not on PATH. Add it to use the commands, e.g.: export PATH=`"$resolvedBinDir`:`$PATH`""
    }

    $verb = if ($DryRun) { 'Would install' } else { 'Installed' }
    Write-Host ("{0} {1} Specrew shell wrapper(s) into {2}:" -f $verb, $installed.Count, $resolvedBinDir) -ForegroundColor Green
    foreach ($name in $installed) { Write-Host " $name" }
    if ($skipped.Count -gt 0) {
        Write-Host ("Skipped (re-run with -Force): {0}" -f ($skipped -join ', ')) -ForegroundColor Yellow
    }
    return 0
}

function Show-InstallShellWrappersUsage {
    @'
specrew install-shell-wrappers [options]
 
Install or refresh the Specrew Unix shell wrappers (macOS/Linux) into a bin directory.
 
Options:
  -BinDir | --bin-dir <path> Target bin directory (default: $HOME/.local/bin)
  -Force | --force Create a missing bin dir; overwrite a non-symlink file
  -WhatIf | --whatif Report planned actions without changing anything
  -Help | --help Show this help
 
Notes:
  - Wrappers are installed as symlinks back to the module so they resolve the module root.
  - Never writes outside the requested bin directory; never edits shell profiles.
  - On Windows this is an explained no-op; use the PowerShell module commands directly.
'@
 | Write-Host
}

if ($MyInvocation.InvocationName -ne '.') {
    $normalized = ConvertFrom-UnixStyleInstallerArgs -BinDir $BinDir -Force:$Force.IsPresent -DryRun:$DryRun.IsPresent -Help:$Help.IsPresent -CliArgs $CliArgs
    if ($normalized.Help) {
        Show-InstallShellWrappersUsage
        exit 0
    }
    $exitCode = Invoke-SpecrewInstallShellWrappers -BinDir $normalized.BinDir -Force:$normalized.Force -DryRun:$normalized.DryRun
    exit $exitCode
}