build/Publish-TechCare.ps1

<#
.SYNOPSIS
    Puerta de calidad previa a publicar TechCare en PowerShell Gallery.
.DESCRIPTION
    Ejecuta, en este orden, la suite de Pester, PSScriptAnalyzer con la
    configuracion del repositorio y Test-ModuleManifest. Si los tres pasan,
    IMPRIME el comando exacto de publicacion y termina.

    No publica. Publicar es una decision del autor y se hace con su clave, en su
    consola. Este script existe para que esa decision se tome con las tres
    comprobaciones en verde delante.
.PARAMETER SkipTests
    Omite Pester. Solo para iterar sobre el empaquetado; nunca antes de publicar.
.PARAMETER PromptForKey
    Pide la clave de la Gallery con Read-Host -AsSecureString, la valida contra
    el formato esperado y la borra de memoria en el bloque finally. Sirve para
    comprobar que la clave esta a mano antes de publicar; tampoco publica.
.EXAMPLE
    ./build/Publish-TechCare.ps1

    Corre las tres comprobaciones e imprime el comando de publicacion.
.EXAMPLE
    ./build/Publish-TechCare.ps1 -PromptForKey

    Ademas pide la clave para confirmar que esta disponible.
.NOTES
    Autor: Eduard Arbona. Licencia MIT.
#>

[CmdletBinding()]
param(
    [switch]$SkipTests,
    [switch]$PromptForKey
)

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

$root = Split-Path -Parent $PSScriptRoot
$manifest = Join-Path -Path $root -ChildPath 'TechCare.psd1'
$settings = Join-Path -Path $root -ChildPath 'PSScriptAnalyzerSettings.psd1'
$fallos = New-Object -TypeName System.Collections.ArrayList

function Write-Paso {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSAvoidUsingWriteHost', '',
        Justification = 'Script de consola interactivo: la salida es para el ojo del autor, no para la tuberia.')]
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Texto,
        [ValidateSet('Titulo', 'Ok', 'Fallo', 'Info')][string]$Tipo = 'Info'
    )
    $color = 'Gray'
    switch ($Tipo) {
        'Titulo' { $color = 'Cyan' }
        'Ok'     { $color = 'Green' }
        'Fallo'  { $color = 'Red' }
    }
    Write-Host $Texto -ForegroundColor $color
}

Write-Paso -Tipo Titulo -Texto ''
Write-Paso -Tipo Titulo -Texto '=== TechCare: puerta de calidad previa a publicar ==='
Write-Paso -Texto ('Repositorio: ' + $root)
Write-Paso -Texto ''

# --- 1. Pester -------------------------------------------------------------
if ($SkipTests) {
    Write-Paso -Tipo Fallo -Texto '[1/3] Pester OMITIDO por -SkipTests. No publiques asi.'
} else {
    Write-Paso -Tipo Titulo -Texto '[1/3] Pester'
    $pester = Get-Module -ListAvailable -Name Pester |
        Where-Object { $_.Version.Major -ge 5 } |
        Sort-Object -Property Version -Descending |
        Select-Object -First 1
    if ($null -eq $pester) {
        [void]$fallos.Add('Pester 5 o superior no esta instalado: Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser')
        Write-Paso -Tipo Fallo -Texto ' Pester 5+ no encontrado.'
    } else {
        Import-Module -Name Pester -MinimumVersion 5.0 -Force
        $cfg = New-PesterConfiguration
        $cfg.Run.Path = Join-Path -Path $root -ChildPath 'Tests'
        $cfg.Run.PassThru = $true
        $cfg.Output.Verbosity = 'Normal'
        $resultado = Invoke-Pester -Configuration $cfg
        Write-Paso -Texto (' Total {0} | Correctas {1} | Fallos {2} | Omitidas {3}' -f
            $resultado.TotalCount, $resultado.PassedCount, $resultado.FailedCount, $resultado.SkippedCount)
        if ($resultado.FailedCount -gt 0) {
            foreach ($t in $resultado.Failed) { Write-Paso -Tipo Fallo -Texto (' - ' + $t.ExpandedPath) }
            [void]$fallos.Add(('Pester: {0} pruebas fallidas' -f $resultado.FailedCount))
        } else {
            Write-Paso -Tipo Ok -Texto ' Pester en verde.'
        }
    }
}

# --- 2. PSScriptAnalyzer ---------------------------------------------------
Write-Paso -Texto ''
Write-Paso -Tipo Titulo -Texto '[2/3] PSScriptAnalyzer'
if ($null -eq (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
    [void]$fallos.Add('PSScriptAnalyzer no esta instalado: Install-Module PSScriptAnalyzer -Scope CurrentUser')
    Write-Paso -Tipo Fallo -Texto ' PSScriptAnalyzer no encontrado.'
} else {
    Import-Module -Name PSScriptAnalyzer -Force
    $hallazgos = @(Invoke-ScriptAnalyzer -Path $root -Recurse -Settings $settings)
    $bloqueantes = @($hallazgos | Where-Object { @('Error', 'ParseError', 'Warning') -contains [string]$_.Severity })
    Write-Paso -Texto (' Hallazgos totales {0} | bloqueantes (Error + Warning) {1}' -f $hallazgos.Count, $bloqueantes.Count)
    foreach ($h in $bloqueantes) {
        Write-Paso -Tipo Fallo -Texto (' - [{0}] {1}:{2} {3}' -f $h.Severity, (Split-Path -Leaf $h.ScriptName), $h.Line, $h.RuleName)
    }
    if ($bloqueantes.Count -gt 0) {
        [void]$fallos.Add(('PSScriptAnalyzer: {0} hallazgos en Error o Warning' -f $bloqueantes.Count))
    } else {
        Write-Paso -Tipo Ok -Texto ' Cero hallazgos en Error y Warning.'
    }
}

# --- 3. Manifiesto ---------------------------------------------------------
Write-Paso -Texto ''
Write-Paso -Tipo Titulo -Texto '[3/3] Test-ModuleManifest'
$version = 'desconocida'
try {
    $info = Test-ModuleManifest -Path $manifest -ErrorAction Stop
    $version = $info.Version.ToString()
    Write-Paso -Texto (' {0} {1} por {2}' -f $info.Name, $version, $info.Author)
    Write-Paso -Texto (' Funciones exportadas: {0}' -f $info.ExportedFunctions.Count)

    $datos = Import-PowerShellDataFile -Path $manifest
    $psdata = $datos.PrivateData.PSData
    foreach ($campo in @('Tags', 'ProjectUri', 'LicenseUri', 'IconUri', 'ReleaseNotes')) {
        if (-not $psdata.ContainsKey($campo) -or -not $psdata[$campo]) {
            [void]$fallos.Add(('El manifiesto no publica ' + $campo))
            Write-Paso -Tipo Fallo -Texto (' - falta ' + $campo)
        }
    }
    # La Gallery renderiza ReleaseNotes COMO las notas: una URL suelta ahi le
    # ensena al lector un enlace donde esperaba leer que cambio.
    if ($psdata.ReleaseNotes -match '^\s*https?://\S+\s*$') {
        [void]$fallos.Add('ReleaseNotes es una URL suelta; debe ser el texto de los cambios')
        Write-Paso -Tipo Fallo -Texto ' - ReleaseNotes no puede ser solo una URL'
    }
    # Un IconUri a la pagina blob de GitHub devuelve HTML y la Gallery no pinta
    # icono; tiene que ser raw.githubusercontent.com.
    if ($psdata.IconUri -notlike 'https://raw.githubusercontent.com/*') {
        [void]$fallos.Add('IconUri debe apuntar a raw.githubusercontent.com, no a la pagina blob')
        Write-Paso -Tipo Fallo -Texto ' - IconUri no apunta a raw'
    }
    if (-not (Test-Path -LiteralPath (Join-Path -Path $root -ChildPath 'assets/icon.png'))) {
        [void]$fallos.Add('assets/icon.png no existe en el repositorio')
        Write-Paso -Tipo Fallo -Texto ' - falta assets/icon.png'
    }
    if ($fallos.Count -eq 0) { Write-Paso -Tipo Ok -Texto ' Manifiesto valido y completo.' }
} catch {
    [void]$fallos.Add('Test-ModuleManifest fallo: ' + $_.Exception.Message)
    Write-Paso -Tipo Fallo -Texto (' ' + $_.Exception.Message)
}

# --- Clave, opcional -------------------------------------------------------
if ($PromptForKey) {
    Write-Paso -Texto ''
    Write-Paso -Tipo Titulo -Texto 'Comprobacion de la clave de PowerShell Gallery'
    $segura = $null
    $puntero = [IntPtr]::Zero
    try {
        $segura = Read-Host -Prompt 'Clave de API de PowerShell Gallery' -AsSecureString
        $puntero = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($segura)
        $plana = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($puntero)
        if ([string]::IsNullOrWhiteSpace($plana)) {
            Write-Paso -Tipo Fallo -Texto ' No se introdujo ninguna clave.'
        } else {
            Write-Paso -Tipo Ok -Texto (' Clave recibida: {0} caracteres. No se muestra, no se guarda y no se envia.' -f $plana.Length)
        }
    } finally {
        # La cadena en claro y el BSTR se borran siempre, tambien si el usuario
        # interrumpe con Ctrl+C: una clave de publicacion no debe sobrevivir en
        # memoria ni acabar en el historial de la consola.
        if ($puntero -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($puntero) }
        if ($null -ne $segura) { $segura.Dispose() }
        Remove-Variable -Name 'plana' -ErrorAction SilentlyContinue
        [System.GC]::Collect()
    }
}

# --- Veredicto -------------------------------------------------------------
Write-Paso -Texto ''
Write-Paso -Tipo Titulo -Texto '=== Veredicto ==='
if ($fallos.Count -gt 0) {
    foreach ($f in $fallos) { Write-Paso -Tipo Fallo -Texto (' BLOQUEA: ' + $f) }
    Write-Paso -Tipo Fallo -Texto ''
    Write-Paso -Tipo Fallo -Texto 'NO publiques. Corrige lo anterior y vuelve a pasar la puerta.'
    exit 1
}

Write-Paso -Tipo Ok -Texto ' Las tres comprobaciones en verde.'
Write-Paso -Texto ''
Write-Paso -Tipo Titulo -Texto '--- Comando de publicacion (ejecutalo tu, este script no publica) ---'
Write-Paso -Texto ''
Write-Paso -Texto ' $clave = Read-Host -Prompt "Clave de API de PowerShell Gallery" -AsSecureString'
Write-Paso -Texto (' Publish-Module -Path "{0}" -Repository PSGallery -NuGetApiKey ' -f $root)
Write-Paso -Texto ' ([Runtime.InteropServices.Marshal]::PtrToStringBSTR('
Write-Paso -Texto ' [Runtime.InteropServices.Marshal]::SecureStringToBSTR($clave)))'
Write-Paso -Texto ''
Write-Paso -Texto (' Despues: Find-Module TechCare -Repository PSGallery (version esperada {0})' -f $version)
Write-Paso -Texto ''
exit 0